1use crate::live::LiveResponse;
2use crate::player::PeopleResponse;
3use crate::schedule::ScheduleResponse;
4use crate::season::{GameType, SeasonInfo, SeasonsResponse};
5use crate::standings::StandingsResponse;
6use crate::stats::StatsResponse;
7use crate::team::{RosterResponse, RosterType, TransactionsResponse};
8use crate::teams::{SportId, TeamsResponse};
9use crate::win_probability::WinProbabilityResponse;
10use std::fmt;
11use std::time::Duration;
12
13use chrono::{DateTime, Datelike, Local, NaiveDate};
14use derive_builder::Builder;
15use reqwest::Client;
16use serde::de::DeserializeOwned;
17
18pub type ApiResult<T> = Result<T, ApiError>;
19
20const BASE_URL: &str = "https://statsapi.mlb.com/api/";
21
22#[derive(Builder, Debug, Clone)]
24#[allow(clippy::upper_case_acronyms)]
25pub struct MLBApi {
26 #[builder(default = "Client::new()")]
27 client: Client,
28 #[builder(default = "Duration::from_secs(10)")]
29 timeout: Duration,
30 #[builder(setter(into), default = "String::from(BASE_URL)")]
31 base_url: String,
32}
33
34#[derive(Debug)]
35pub enum ApiError {
36 Network(reqwest::Error, String),
37 API(reqwest::Error, String),
38 Parsing(reqwest::Error, String),
39}
40
41impl ApiError {
42 pub fn log(&self) -> String {
43 match self {
44 ApiError::Network(e, url) => format!("Network error for {url}: {e:?}"),
45 ApiError::API(e, url) => format!("API error for {url}: {e:?}"),
46 ApiError::Parsing(e, url) => format!("Parsing error for {url}: {e:?}"),
47 }
48 }
49}
50
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum StatGroup {
56 Hitting,
57 Pitching,
58 }
65
66impl fmt::Display for StatGroup {
68 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
69 match self {
70 StatGroup::Hitting => write!(f, "hitting"),
71 StatGroup::Pitching => write!(f, "pitching"),
72 }
73 }
74}
75
76impl StatGroup {
77 pub fn default_sort_stat(&self) -> &'static str {
79 match self {
80 StatGroup::Hitting => "plateAppearances",
81 StatGroup::Pitching => "inningsPitched",
82 }
83 }
84}
85
86impl MLBApi {
87 pub async fn get_todays_schedule(&self) -> ApiResult<ScheduleResponse> {
88 let url = format!(
89 "{}v1/schedule?sportId=1,51&hydrate=linescore,probablePitcher,stats",
90 self.base_url
91 );
92 self.get(url).await
93 }
94
95 pub async fn get_schedule_date(&self, date: NaiveDate) -> ApiResult<ScheduleResponse> {
96 let url = format!(
97 "{}v1/schedule?sportId=1,51&hydrate=linescore,probablePitcher,stats&date={}",
98 self.base_url,
99 date.format("%Y-%m-%d")
100 );
101 self.get(url).await
102 }
103
104 pub async fn get_live_data(&self, game_id: u64) -> ApiResult<LiveResponse> {
105 if game_id == 0 {
106 return Ok(LiveResponse::default());
107 }
108 let url = format!(
109 "{}v1.1/game/{}/feed/live?language=en",
110 self.base_url, game_id
111 );
112 self.get(url).await
113 }
114
115 pub async fn get_win_probability(&self, game_id: u64) -> ApiResult<WinProbabilityResponse> {
116 if game_id == 0 {
117 return Ok(WinProbabilityResponse::default());
118 }
119 let url = format!(
120 "{}v1/game/{}/winProbability?fields=homeTeamWinProbability&fields=awayTeamWinProbability&fields=homeTeamWinProbabilityAdded&fields=atBatIndex&fields=about&fields=inning&fields=isTopInning&fields=captivatingIndex&fields=leverageIndex",
121 self.base_url, game_id
122 );
123 self.get(url).await
124 }
125
126 pub async fn get_season_info(&self, year: i32) -> ApiResult<Option<SeasonInfo>> {
127 let url = format!("{}v1/seasons/{}?sportId=1", self.base_url, year);
128 let resp = self.get::<SeasonsResponse>(url).await?;
129 Ok(resp.seasons.into_iter().next())
130 }
131
132 pub async fn get_standings(
133 &self,
134 date: NaiveDate,
135 game_type: GameType,
136 ) -> ApiResult<StandingsResponse> {
137 let url = match game_type {
138 GameType::SpringTraining => format!(
139 "{}v1/standings?sportId=1&season={}&standingsType=springTraining&leagueId=103,104&hydrate=team",
140 self.base_url,
141 date.year(),
142 ),
143 GameType::RegularSeason => format!(
144 "{}v1/standings?sportId=1&season={}&date={}&leagueId=103,104&hydrate=team",
145 self.base_url,
146 date.year(),
147 date.format("%Y-%m-%d"),
148 ),
149 };
150 self.get(url).await
151 }
152
153 pub async fn get_team_stats(
154 &self,
155 group: StatGroup,
156 game_type: GameType,
157 ) -> ApiResult<StatsResponse> {
158 let local: DateTime<Local> = Local::now();
159 let mut url = format!(
160 "{}v1/teams/stats?sportId=1&stats=season&season={}&group={}",
161 self.base_url,
162 local.year(),
163 group
164 );
165 if game_type == GameType::SpringTraining {
166 url.push_str("&gameType=S");
167 }
168 self.get(url).await
169 }
170
171 pub async fn get_team_stats_on_date(
172 &self,
173 group: StatGroup,
174 date: NaiveDate,
175 game_type: GameType,
176 ) -> ApiResult<StatsResponse> {
177 let mut url = format!(
178 "{}v1/teams/stats?sportId=1&stats=byDateRange&season={}&endDate={}&group={}",
179 self.base_url,
180 date.year(),
181 date.format("%Y-%m-%d"),
182 group
183 );
184 if game_type == GameType::SpringTraining {
185 url.push_str("&gameType=S");
186 }
187 self.get(url).await
188 }
189
190 pub async fn get_player_stats(
191 &self,
192 group: StatGroup,
193 game_type: GameType,
194 ) -> ApiResult<StatsResponse> {
195 let local: DateTime<Local> = Local::now();
196 let sort = group.default_sort_stat();
197 let mut url = format!(
198 "{}v1/stats?sportId=1&stats=season&season={}&group={}&limit=3000&sortStat={}&order=desc&playerPool=ALL",
199 self.base_url,
200 local.year(),
201 group,
202 sort
203 );
204 if game_type == GameType::SpringTraining {
205 url.push_str("&gameType=S");
206 }
207 self.get(url).await
208 }
209
210 pub async fn get_player_stats_on_date(
211 &self,
212 group: StatGroup,
213 date: NaiveDate,
214 game_type: GameType,
215 ) -> ApiResult<StatsResponse> {
216 let sort = group.default_sort_stat();
217 let url = match game_type {
218 GameType::SpringTraining => format!(
220 "{}v1/stats?sportId=1&stats=season&season={}&group={}&limit=3000&sortStat={}&order=desc&gameType=S&playerPool=ALL",
221 self.base_url,
222 date.year(),
223 group,
224 sort
225 ),
226 GameType::RegularSeason => {
227 let current_year = Local::now().year();
228 if date.year() < current_year {
229 format!(
232 "{}v1/stats?sportId=1&stats=season&season={}&group={}&limit=3000&sortStat={}&order=desc&playerPool=ALL",
233 self.base_url,
234 date.year(),
235 group,
236 sort
237 )
238 } else {
239 format!(
240 "{}v1/stats?sportId=1&stats=byDateRange&season={}&endDate={}&group={}&limit=3000&sortStat={}&order=desc&playerPool=ALL",
241 self.base_url,
242 date.year(),
243 date.format("%Y-%m-%d"),
244 group,
245 sort
246 )
247 }
248 }
249 };
250 self.get(url).await
251 }
252
253 pub async fn get_player_profile(
255 &self,
256 person_id: u64,
257 group: StatGroup,
258 season: i32,
259 game_type: GameType,
260 ) -> ApiResult<PeopleResponse> {
261 let game_type_param = match game_type {
262 GameType::SpringTraining => ",gameType=S",
263 GameType::RegularSeason => "",
264 };
265 let url = format!(
266 "{}v1/people/{}?hydrate=currentTeam,draft,stats(group=[{}],type=[season,yearByYear,career,gameLog],season={}{})",
267 self.base_url, person_id, group, season, game_type_param
268 );
269 self.get(url).await
270 }
271
272 pub async fn get_team_schedule(
273 &self,
274 team_id: u16,
275 season: i32,
276 ) -> ApiResult<ScheduleResponse> {
277 let url = format!(
278 "{}v1/schedule?teamId={}&season={}&sportId=1",
279 self.base_url, team_id, season
280 );
281 self.get(url).await
282 }
283
284 pub async fn get_team_roster(
285 &self,
286 team_id: u16,
287 season: i32,
288 roster_type: RosterType,
289 ) -> ApiResult<RosterResponse> {
290 let url = format!(
291 "{}v1/teams/{}/roster/{}?season={}&hydrate=person",
292 self.base_url, team_id, roster_type, season
293 );
294 self.get(url).await
295 }
296
297 pub async fn get_team_transactions(
298 &self,
299 team_id: u16,
300 start_date: NaiveDate,
301 end_date: NaiveDate,
302 ) -> ApiResult<TransactionsResponse> {
303 let url = format!(
304 "{}v1/transactions?teamId={}&startDate={}&endDate={}",
305 self.base_url,
306 team_id,
307 start_date.format("%m/%d/%Y"),
308 end_date.format("%m/%d/%Y"),
309 );
310 self.get(url).await
311 }
312
313 pub async fn get_teams(&self, sport_ids: &[SportId]) -> ApiResult<TeamsResponse> {
314 let ids: Vec<String> = sport_ids.iter().map(|id| id.to_string()).collect();
315 let url = format!(
316 "{}v1/teams?sportIds={}&fields=teams,id,name,division,teamName,abbreviation,sport",
317 self.base_url,
318 ids.join(",")
319 );
320 self.get(url).await
321 }
322
323 async fn get<T: Default + DeserializeOwned>(&self, url: String) -> ApiResult<T> {
324 let response = self
325 .client
326 .get(&url)
327 .timeout(self.timeout)
328 .send()
329 .await
330 .map_err(|err| ApiError::Network(err, url.clone()))?;
331
332 let status = response.status();
333 match response.error_for_status() {
334 Ok(res) => res
335 .json::<T>()
336 .await
337 .map_err(|err| ApiError::Parsing(err, url.clone())),
338 Err(err) => {
340 if status.is_client_error() {
341 Ok(T::default())
343 } else {
344 Err(ApiError::API(err, url.clone()))
345 }
346 }
347 }
348 }
349}
350
351#[test]
352fn test_stat_group_lowercase() {
353 assert_eq!("hitting".to_string(), StatGroup::Hitting.to_string());
354 assert_eq!("pitching".to_string(), StatGroup::Pitching.to_string());
355}