Skip to main content

mlbt_api/
client.rs

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/// MLB API object
23#[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/// The available stat groups. These are taken from the "meta" endpoint:
52/// https://statsapi.mlb.com/api/v1/statGroups
53/// I only need to use Hitting and Pitching for now.
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum StatGroup {
56    Hitting,
57    Pitching,
58    // Fielding,
59    // Catching,
60    // Running,
61    // Game,
62    // Team,
63    // Streak,
64}
65
66/// Display the StatGroup in all lowercase.
67impl 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    /// The default sort stat for player leaderboards.
78    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            // Spring training doesn't work well with byDateRange, use season instead.
219            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                    // For past seasons use season stats because its way faster, and you can't use
230                    // a date range anyway.
231                    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    /// Fetch player bio and all stats in a single hydrated call.
254    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            // 400-5xx returns errors
339            Err(err) => {
340                if status.is_client_error() {
341                    // just swallow 4xx responses
342                    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}