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

1"""Outbound adapter — translates domain calls into AthleteOne API HTTP requests. 

2 

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 

7 

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""" 

12 

13import asyncio 

14import logging 

15from typing import Any 

16 

17import httpx 

18 

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) 

29 

30logger = logging.getLogger(__name__) 

31 

32_DEFAULT_BASE_URL = "https://api.athleteone.com" 

33_API = "/api" 

34 

35 

36class AthleteOneAdapter: 

37 """Calls the AthleteOne (Total Global Sports) public API for ECNL/ECRL data. 

38 

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 """ 

43 

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. 

46 

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) 

54 

55 async def _get(self, path: str) -> Any: 

56 """Execute a GET request and return the unwrapped ``data`` payload. 

57 

58 Args: 

59 path: URL path relative to base_url (including the ``/api`` prefix). 

60 

61 Returns: 

62 The value of the response's ``data`` field. 

63 

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) 

79 

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) 

86 

87 async def get_event_overview(self, event_id: int) -> EventOverview: 

88 """Return the division/flight navigation tree plus event metadata. 

89 

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) 

101 

102 async def get_standings(self, event_id: int, division_id: int, flight_id: int) -> Standings: 

103 """Return the standings table for a flight. 

104 

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) 

111 

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 [])] 

116 

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 [])] 

121 

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 [])] 

126 

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 [])] 

131 

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 [])] 

136 

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 {} 

141 

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 {} 

146 

147 async def get_org_club_events(self, org_id: int) -> list[int]: 

148 """Return distinct active event IDs across an org's clubs. 

149 

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) 

156 

157 

158def _unwrap(payload: Any, path: str) -> Any: 

159 """Return the ``data`` field of a TGS envelope, raising on error envelopes. 

160 

161 The API returns ``{"result": "success", "data": ...}`` on success and a 

162 ``{"result": "error", ...}`` (sometimes with HTTP 200) on failure. 

163 

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")