use crate::config::ClientConfig;
use crate::date::GameDate;
use crate::error::NHLApiError;
use crate::http_client::{Endpoint, HttpClient};
use crate::ids::GameId;
use crate::types::{
Boxscore, ClubStats, DailySchedule, DailyScores, Franchise, FranchisesResponse, GameMatchup,
GameStory, GameType, PlayByPlay, PlayerGameLog, PlayerLanding, PlayerSearchResult, Roster,
SeasonGameTypes, SeasonInfo, SeasonSeriesMatchup, SeasonsResponse, ShiftChart, Standing,
StandingsResponse, Team, TeamScheduleResponse, WeeklyScheduleResponse,
};
use std::collections::HashMap;
pub struct Client {
client: HttpClient,
}
impl Client {
pub fn new() -> Result<Self, NHLApiError> {
Self::with_config(ClientConfig::default())
}
pub fn with_config(config: ClientConfig) -> Result<Self, NHLApiError> {
Ok(Self {
client: HttpClient::new(config)?,
})
}
fn resolve_date_or(date: Option<GameDate>, default: GameDate) -> GameDate {
date.unwrap_or(default)
}
pub async fn teams(&self, date: Option<GameDate>) -> Result<Vec<Team>, NHLApiError> {
let date = Self::resolve_date_or(date, GameDate::default());
let standings_response = self.fetch_standings_data(&date.to_api_string()).await?;
let teams: Vec<Team> = standings_response
.standings
.iter()
.map(|standing| standing.to_team())
.collect();
Ok(teams)
}
async fn fetch_standings_data(&self, date: &str) -> Result<StandingsResponse, NHLApiError> {
self.client
.get_json(Endpoint::ApiWebV1, &format!("standings/{}", date), None)
.await
}
pub async fn current_league_standings(&self) -> Result<Vec<Standing>, NHLApiError> {
self.league_standings_for_date(&GameDate::default()).await
}
pub async fn league_standings_for_date(
&self,
date: &GameDate,
) -> Result<Vec<Standing>, NHLApiError> {
Ok(self
.fetch_standings_data(&date.to_api_string())
.await?
.standings)
}
pub async fn league_standings_for_season(
&self,
season_id: i64,
) -> Result<Vec<Standing>, NHLApiError> {
let seasons = self.season_standing_manifest().await?;
let season_data = seasons
.iter()
.find(|s| s.id == season_id)
.ok_or_else(|| NHLApiError::Other(format!("Invalid Season Id {}", season_id)))?;
Ok(self
.fetch_standings_data(&season_data.standings_end)
.await?
.standings)
}
pub async fn season_standing_manifest(&self) -> Result<Vec<SeasonInfo>, NHLApiError> {
let response: SeasonsResponse = self
.client
.get_json(Endpoint::ApiWebV1, "standings-season", None)
.await?;
Ok(response.seasons)
}
async fn fetch_gamecenter<T: serde::de::DeserializeOwned>(
&self,
game_id: impl Into<GameId>,
resource: &str,
) -> Result<T, NHLApiError> {
let game_id = game_id.into();
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("gamecenter/{}/{}", game_id, resource),
None,
)
.await
}
pub async fn boxscore(&self, game_id: impl Into<GameId>) -> Result<Boxscore, NHLApiError> {
self.fetch_gamecenter(game_id, "boxscore").await
}
pub async fn play_by_play(
&self,
game_id: impl Into<GameId>,
) -> Result<PlayByPlay, NHLApiError> {
self.fetch_gamecenter(game_id, "play-by-play").await
}
pub async fn landing(&self, game_id: impl Into<GameId>) -> Result<GameMatchup, NHLApiError> {
self.fetch_gamecenter(game_id, "landing").await
}
pub async fn season_series(
&self,
game_id: impl Into<GameId>,
) -> Result<SeasonSeriesMatchup, NHLApiError> {
self.fetch_gamecenter(game_id, "right-rail").await
}
pub async fn game_story(&self, game_id: impl Into<GameId>) -> Result<GameStory, NHLApiError> {
let game_id = game_id.into();
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("wsc/game-story/{}", game_id),
None,
)
.await
}
pub async fn shift_chart(&self, game_id: impl Into<GameId>) -> Result<ShiftChart, NHLApiError> {
let game_id = game_id.into();
let cayenne_expr = format!(
"gameId={} and ((duration != '00:00' and typeCode = 517) or typeCode != 517 )",
game_id
);
let mut params = HashMap::new();
params.insert("cayenneExp".to_string(), cayenne_expr);
params.insert("exclude".to_string(), "eventDetails".to_string());
self.client
.get_json(Endpoint::ApiStats, "en/shiftcharts", Some(params))
.await
}
async fn fetch_weekly_schedule(
&self,
date_string: &str,
) -> Result<WeeklyScheduleResponse, NHLApiError> {
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("schedule/{}", date_string),
None,
)
.await
}
fn extract_daily_schedule(
&self,
schedule_data: WeeklyScheduleResponse,
date_string: String,
) -> DailySchedule {
let games = schedule_data
.game_week
.into_iter()
.find(|day| day.date == date_string)
.map(|day| day.games)
.unwrap_or_default();
DailySchedule {
next_start_date: Some(schedule_data.next_start_date),
previous_start_date: Some(schedule_data.previous_start_date),
date: date_string,
number_of_games: games.len(),
games,
}
}
pub async fn daily_schedule(
&self,
date: Option<GameDate>,
) -> Result<DailySchedule, NHLApiError> {
let date = Self::resolve_date_or(date, GameDate::today());
let date_string = date.to_api_string();
let schedule_data = self.fetch_weekly_schedule(&date_string).await?;
Ok(self.extract_daily_schedule(schedule_data, date_string))
}
pub async fn weekly_schedule(
&self,
date: Option<GameDate>,
) -> Result<WeeklyScheduleResponse, NHLApiError> {
let date = Self::resolve_date_or(date, GameDate::default());
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("schedule/{}", date.to_api_string()),
None,
)
.await
}
pub async fn player_landing(&self, player_id: i64) -> Result<PlayerLanding, NHLApiError> {
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("player/{}/landing", player_id),
None,
)
.await
}
pub async fn player_game_log(
&self,
player_id: i64,
season: i32,
game_type: GameType,
) -> Result<PlayerGameLog, NHLApiError> {
let mut game_log: PlayerGameLog = self
.client
.get_json(
Endpoint::ApiWebV1,
&format!(
"player/{}/game-log/{}/{}",
player_id,
season,
game_type.to_int()
),
None,
)
.await?;
game_log.player_id = player_id;
Ok(game_log)
}
pub async fn search_player(
&self,
query: &str,
limit: Option<i32>,
) -> Result<Vec<PlayerSearchResult>, NHLApiError> {
let mut params = HashMap::new();
params.insert("culture".to_string(), "en-us".to_string());
params.insert("q".to_string(), query.to_string());
params.insert("limit".to_string(), limit.unwrap_or(20).to_string());
self.client
.get_json(Endpoint::SearchV1, "search/player", Some(params))
.await
}
pub async fn franchises(&self) -> Result<Vec<Franchise>, NHLApiError> {
let response: FranchisesResponse = self
.client
.get_json(Endpoint::ApiStats, "en/franchise", None)
.await?;
Ok(response.data)
}
pub async fn club_stats(
&self,
team_abbr: &str,
season: i32,
game_type: GameType,
) -> Result<ClubStats, NHLApiError> {
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("club-stats/{}/{}/{}", team_abbr, season, game_type.to_int()),
None,
)
.await
}
pub async fn club_stats_season(
&self,
team_abbr: &str,
) -> Result<Vec<SeasonGameTypes>, NHLApiError> {
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("club-stats-season/{}", team_abbr),
None,
)
.await
}
pub async fn roster_current(&self, team_abbr: &str) -> Result<Roster, NHLApiError> {
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("roster/{}/current", team_abbr),
None,
)
.await
}
pub async fn roster_season(&self, team_abbr: &str, season: i32) -> Result<Roster, NHLApiError> {
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("roster/{}/{}", team_abbr, season),
None,
)
.await
}
pub async fn daily_scores(&self, date: Option<GameDate>) -> Result<DailyScores, NHLApiError> {
let date = Self::resolve_date_or(date, GameDate::today());
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("score/{}", date.to_api_string()),
None,
)
.await
}
pub async fn team_weekly_schedule(
&self,
team_abbr: &str,
date: Option<GameDate>,
) -> Result<TeamScheduleResponse, NHLApiError> {
let date = Self::resolve_date_or(date, GameDate::today());
self.client
.get_json(
Endpoint::ApiWebV1,
&format!("club-schedule/{}/week/{}", team_abbr, date.to_api_string()),
None,
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::date::GameDate;
use chrono::NaiveDate;
#[test]
fn test_client_new() {
let client = Client::new();
assert!(client.is_ok());
}
#[test]
fn test_client_with_default_config() {
let config = ClientConfig::default();
let client = Client::with_config(config);
assert!(client.is_ok());
}
#[test]
fn test_client_with_custom_config() {
let config = ClientConfig {
timeout: std::time::Duration::from_secs(60),
follow_redirects: false,
ssl_verify: true,
};
let client = Client::with_config(config);
assert!(client.is_ok());
}
#[test]
fn test_resolve_date_or_with_some() {
let date = GameDate::Date(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
let resolved = Client::resolve_date_or(Some(date.clone()), GameDate::default());
assert_eq!(resolved.to_api_string(), "2024-01-15");
}
#[test]
fn test_resolve_date_or_with_none_default_now() {
let resolved = Client::resolve_date_or(None, GameDate::default());
assert_eq!(resolved.to_api_string(), "now");
}
#[test]
fn test_resolve_date_or_with_some_today() {
let date = GameDate::Date(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
let resolved = Client::resolve_date_or(Some(date.clone()), GameDate::today());
assert_eq!(resolved.to_api_string(), "2024-01-15");
}
#[test]
fn test_resolve_date_or_with_none_default_today() {
let resolved = Client::resolve_date_or(None, GameDate::today());
assert_ne!(resolved.to_api_string(), "now");
}
#[test]
fn test_extract_daily_schedule_found() {
let client = Client::new().unwrap();
let weekly_response = WeeklyScheduleResponse {
next_start_date: "2024-01-15".to_string(),
previous_start_date: "2024-01-01".to_string(),
game_week: vec![crate::types::schedule::GameDay {
date: "2024-01-08".to_string(),
games: vec![],
}],
};
let result = client.extract_daily_schedule(weekly_response, "2024-01-08".to_string());
assert_eq!(result.date, "2024-01-08");
assert_eq!(result.number_of_games, 0); assert_eq!(result.next_start_date, Some("2024-01-15".to_string()));
assert_eq!(result.previous_start_date, Some("2024-01-01".to_string()));
}
#[test]
fn test_extract_daily_schedule_not_found() {
let client = Client::new().unwrap();
let weekly_response = WeeklyScheduleResponse {
next_start_date: "2024-01-15".to_string(),
previous_start_date: "2024-01-01".to_string(),
game_week: vec![crate::types::schedule::GameDay {
date: "2024-01-08".to_string(),
games: vec![],
}],
};
let result = client.extract_daily_schedule(weekly_response, "2024-01-09".to_string());
assert_eq!(result.date, "2024-01-09");
assert_eq!(result.number_of_games, 0);
assert!(result.games.is_empty());
}
#[test]
fn test_extract_daily_schedule_with_games() {
use crate::types::game_state::GameState;
use crate::types::schedule::{ScheduleGame, ScheduleTeam};
let client = Client::new().unwrap();
let weekly_response = WeeklyScheduleResponse {
next_start_date: "2024-01-15".to_string(),
previous_start_date: "2024-01-01".to_string(),
game_week: vec![crate::types::schedule::GameDay {
date: "2024-01-08".to_string(),
games: vec![ScheduleGame {
id: 2023020001,
game_type: GameType::RegularSeason,
game_date: Some("2024-01-08".to_string()),
start_time_utc: "2024-01-08T23:00:00Z".to_string(),
away_team: ScheduleTeam {
id: 8,
abbrev: "MTL".to_string(),
logo: "logo.png".to_string(),
score: Some(2),
place_name: None,
},
home_team: ScheduleTeam {
id: 6,
abbrev: "BOS".to_string(),
logo: "logo.png".to_string(),
score: Some(3),
place_name: None,
},
game_state: GameState::Final,
}],
}],
};
let result = client.extract_daily_schedule(weekly_response, "2024-01-08".to_string());
assert_eq!(result.date, "2024-01-08");
assert_eq!(result.number_of_games, 1);
assert_eq!(result.games.len(), 1);
assert_eq!(result.games[0].id, 2023020001);
}
#[test]
fn test_extract_daily_schedule_empty_game_week() {
let client = Client::new().unwrap();
let weekly_response = WeeklyScheduleResponse {
next_start_date: "2024-01-15".to_string(),
previous_start_date: "2024-01-01".to_string(),
game_week: vec![],
};
let result = client.extract_daily_schedule(weekly_response, "2024-01-08".to_string());
assert_eq!(result.date, "2024-01-08");
assert_eq!(result.number_of_games, 0);
assert!(result.games.is_empty());
}
#[test]
fn test_into_game_id_accepts_i64() {
let game_id_i64: i64 = 2023020001;
let converted: GameId = game_id_i64.into();
assert_eq!(converted.as_i64(), 2023020001);
}
#[test]
fn test_game_id_from_i64() {
let game_id = GameId::from(2023020001);
assert_eq!(game_id.as_i64(), 2023020001);
}
}