use crate::live::LiveResponse;
use crate::player::PeopleResponse;
use crate::schedule::ScheduleResponse;
use crate::season::{GameType, SeasonInfo, SeasonsResponse};
use crate::standings::StandingsResponse;
use crate::stats::StatsResponse;
use crate::team::{RosterResponse, RosterType, TransactionsResponse};
use crate::teams::{SportId, TeamsResponse};
use crate::win_probability::WinProbabilityResponse;
use std::fmt;
use std::time::Duration;
use chrono::{DateTime, Datelike, Local, NaiveDate};
use derive_builder::Builder;
use reqwest::Client;
use serde::de::DeserializeOwned;
pub type ApiResult<T> = Result<T, ApiError>;
const BASE_URL: &str = "https://statsapi.mlb.com/api/";
#[derive(Builder, Debug, Clone)]
#[allow(clippy::upper_case_acronyms)]
pub struct MLBApi {
#[builder(default = "Client::new()")]
client: Client,
#[builder(default = "Duration::from_secs(10)")]
timeout: Duration,
#[builder(setter(into), default = "String::from(BASE_URL)")]
base_url: String,
}
#[derive(Debug)]
pub enum ApiError {
Network(reqwest::Error, String),
API(reqwest::Error, String),
Parsing(reqwest::Error, String),
}
impl ApiError {
pub fn log(&self) -> String {
match self {
ApiError::Network(e, url) => format!("Network error for {url}: {e:?}"),
ApiError::API(e, url) => format!("API error for {url}: {e:?}"),
ApiError::Parsing(e, url) => format!("Parsing error for {url}: {e:?}"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum StatGroup {
Hitting,
Pitching,
}
impl fmt::Display for StatGroup {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
StatGroup::Hitting => write!(f, "hitting"),
StatGroup::Pitching => write!(f, "pitching"),
}
}
}
impl StatGroup {
pub fn default_sort_stat(&self) -> &'static str {
match self {
StatGroup::Hitting => "plateAppearances",
StatGroup::Pitching => "inningsPitched",
}
}
}
impl MLBApi {
pub async fn get_todays_schedule(&self) -> ApiResult<ScheduleResponse> {
let url = format!(
"{}v1/schedule?sportId=1,51&hydrate=linescore,probablePitcher,stats",
self.base_url
);
self.get(url).await
}
pub async fn get_schedule_date(&self, date: NaiveDate) -> ApiResult<ScheduleResponse> {
let url = format!(
"{}v1/schedule?sportId=1,51&hydrate=linescore,probablePitcher,stats&date={}",
self.base_url,
date.format("%Y-%m-%d")
);
self.get(url).await
}
pub async fn get_live_data(&self, game_id: u64) -> ApiResult<LiveResponse> {
if game_id == 0 {
return Ok(LiveResponse::default());
}
let url = format!(
"{}v1.1/game/{}/feed/live?language=en",
self.base_url, game_id
);
self.get(url).await
}
pub async fn get_win_probability(&self, game_id: u64) -> ApiResult<WinProbabilityResponse> {
if game_id == 0 {
return Ok(WinProbabilityResponse::default());
}
let url = format!(
"{}v1/game/{}/winProbability?fields=homeTeamWinProbability&fields=awayTeamWinProbability&fields=homeTeamWinProbabilityAdded&fields=atBatIndex&fields=about&fields=inning&fields=isTopInning&fields=captivatingIndex&fields=leverageIndex",
self.base_url, game_id
);
self.get(url).await
}
pub async fn get_season_info(&self, year: i32) -> ApiResult<Option<SeasonInfo>> {
let url = format!("{}v1/seasons/{}?sportId=1", self.base_url, year);
let resp = self.get::<SeasonsResponse>(url).await?;
Ok(resp.seasons.into_iter().next())
}
pub async fn get_standings(
&self,
date: NaiveDate,
game_type: GameType,
) -> ApiResult<StandingsResponse> {
let url = match game_type {
GameType::SpringTraining => format!(
"{}v1/standings?sportId=1&season={}&standingsType=springTraining&leagueId=103,104&hydrate=team",
self.base_url,
date.year(),
),
GameType::RegularSeason => format!(
"{}v1/standings?sportId=1&season={}&date={}&leagueId=103,104&hydrate=team",
self.base_url,
date.year(),
date.format("%Y-%m-%d"),
),
};
self.get(url).await
}
pub async fn get_team_stats(
&self,
group: StatGroup,
game_type: GameType,
) -> ApiResult<StatsResponse> {
let local: DateTime<Local> = Local::now();
let mut url = format!(
"{}v1/teams/stats?sportId=1&stats=season&season={}&group={}",
self.base_url,
local.year(),
group
);
if game_type == GameType::SpringTraining {
url.push_str("&gameType=S");
}
self.get(url).await
}
pub async fn get_team_stats_on_date(
&self,
group: StatGroup,
date: NaiveDate,
game_type: GameType,
) -> ApiResult<StatsResponse> {
let mut url = format!(
"{}v1/teams/stats?sportId=1&stats=byDateRange&season={}&endDate={}&group={}",
self.base_url,
date.year(),
date.format("%Y-%m-%d"),
group
);
if game_type == GameType::SpringTraining {
url.push_str("&gameType=S");
}
self.get(url).await
}
pub async fn get_player_stats(
&self,
group: StatGroup,
game_type: GameType,
) -> ApiResult<StatsResponse> {
let local: DateTime<Local> = Local::now();
let sort = group.default_sort_stat();
let mut url = format!(
"{}v1/stats?sportId=1&stats=season&season={}&group={}&limit=3000&sortStat={}&order=desc&playerPool=ALL",
self.base_url,
local.year(),
group,
sort
);
if game_type == GameType::SpringTraining {
url.push_str("&gameType=S");
}
self.get(url).await
}
pub async fn get_player_stats_on_date(
&self,
group: StatGroup,
date: NaiveDate,
game_type: GameType,
) -> ApiResult<StatsResponse> {
let sort = group.default_sort_stat();
let url = match game_type {
GameType::SpringTraining => format!(
"{}v1/stats?sportId=1&stats=season&season={}&group={}&limit=3000&sortStat={}&order=desc&gameType=S&playerPool=ALL",
self.base_url,
date.year(),
group,
sort
),
GameType::RegularSeason => {
let current_year = Local::now().year();
if date.year() < current_year {
format!(
"{}v1/stats?sportId=1&stats=season&season={}&group={}&limit=3000&sortStat={}&order=desc&playerPool=ALL",
self.base_url,
date.year(),
group,
sort
)
} else {
format!(
"{}v1/stats?sportId=1&stats=byDateRange&season={}&endDate={}&group={}&limit=3000&sortStat={}&order=desc&playerPool=ALL",
self.base_url,
date.year(),
date.format("%Y-%m-%d"),
group,
sort
)
}
}
};
self.get(url).await
}
pub async fn get_player_profile(
&self,
person_id: u64,
group: StatGroup,
season: i32,
game_type: GameType,
) -> ApiResult<PeopleResponse> {
let game_type_param = match game_type {
GameType::SpringTraining => ",gameType=S",
GameType::RegularSeason => "",
};
let url = format!(
"{}v1/people/{}?hydrate=currentTeam,draft,stats(group=[{}],type=[season,yearByYear,career,gameLog],season={}{})",
self.base_url, person_id, group, season, game_type_param
);
self.get(url).await
}
pub async fn get_team_schedule(
&self,
team_id: u16,
season: i32,
) -> ApiResult<ScheduleResponse> {
let url = format!(
"{}v1/schedule?teamId={}&season={}&sportId=1",
self.base_url, team_id, season
);
self.get(url).await
}
pub async fn get_team_roster(
&self,
team_id: u16,
season: i32,
roster_type: RosterType,
) -> ApiResult<RosterResponse> {
let url = format!(
"{}v1/teams/{}/roster/{}?season={}&hydrate=person",
self.base_url, team_id, roster_type, season
);
self.get(url).await
}
pub async fn get_team_transactions(
&self,
team_id: u16,
start_date: NaiveDate,
end_date: NaiveDate,
) -> ApiResult<TransactionsResponse> {
let url = format!(
"{}v1/transactions?teamId={}&startDate={}&endDate={}",
self.base_url,
team_id,
start_date.format("%m/%d/%Y"),
end_date.format("%m/%d/%Y"),
);
self.get(url).await
}
pub async fn get_teams(&self, sport_ids: &[SportId]) -> ApiResult<TeamsResponse> {
let ids: Vec<String> = sport_ids.iter().map(|id| id.to_string()).collect();
let url = format!(
"{}v1/teams?sportIds={}&fields=teams,id,name,division,teamName,abbreviation,sport",
self.base_url,
ids.join(",")
);
self.get(url).await
}
async fn get<T: Default + DeserializeOwned>(&self, url: String) -> ApiResult<T> {
let response = self
.client
.get(&url)
.timeout(self.timeout)
.send()
.await
.map_err(|err| ApiError::Network(err, url.clone()))?;
let status = response.status();
match response.error_for_status() {
Ok(res) => res
.json::<T>()
.await
.map_err(|err| ApiError::Parsing(err, url.clone())),
Err(err) => {
if status.is_client_error() {
Ok(T::default())
} else {
Err(ApiError::API(err, url.clone()))
}
}
}
}
}
#[test]
fn test_stat_group_lowercase() {
assert_eq!("hitting".to_string(), StatGroup::Hitting.to_string());
assert_eq!("pitching".to_string(), StatGroup::Pitching.to_string());
}