use url::Url;
use crate::{
id::{CircuitID, ConstructorID, DriverID, RoundID, SeasonID, StatusID},
jolpica::{api::JOLPICA_API_PAGINATION, response::Pagination},
};
#[cfg(doc)]
use crate::jolpica::{
self,
agent::Agent,
api,
response::{
Circuit, Constructor, Driver, QualifyingResult, Race, RaceResult, Response, Season, SprintResult, Status,
},
};
#[derive(Clone, Debug)]
pub enum Resource {
SeasonList(Filters),
DriverInfo(Filters),
#[allow(clippy::doc_markdown)] ConstructorInfo(Filters),
CircuitInfo(Filters),
RaceSchedule(Filters),
QualifyingResults(Filters),
SprintResults(Filters),
RaceResults(Filters),
FinishingStatus(Filters),
LapTimes(LapTimeFilters),
PitStops(PitStopFilters),
#[doc(hidden)]
DriverStandings,
#[doc(hidden)]
ConstructorStandings,
}
impl Resource {
pub fn to_url(&self) -> Url {
self.to_url_with_base_and_opt_page(crate::jolpica::api::JOLPICA_API_BASE_URL, None)
}
pub fn to_url_with(&self, page: Page) -> Url {
self.to_url_with_base_and_opt_page(crate::jolpica::api::JOLPICA_API_BASE_URL, Some(page))
}
pub fn to_url_with_base_and_opt_page(&self, base_url: &str, page: Option<Page>) -> Url {
let mut url = Url::parse(&format!("{}{}.json", base_url, self.to_endpoint())).unwrap();
if let Some(page) = page {
#[allow(unused_results)]
let _ = url
.query_pairs_mut()
.extend_pairs([("limit", page.limit.to_string()), ("offset", page.offset.to_string())]);
}
url
}
pub fn to_endpoint(&self) -> String {
type DynFF<'a> = &'a dyn FiltersFormatter;
#[allow(trivial_casts)]
let (resource_key, filters) = match self {
Self::SeasonList(f) => ("/seasons", f as DynFF<'_>),
Self::DriverInfo(f) => ("/drivers", f as DynFF<'_>),
Self::ConstructorInfo(f) => ("/constructors", f as DynFF<'_>),
Self::CircuitInfo(f) => ("/circuits", f as DynFF<'_>),
Self::RaceSchedule(f) => ("/races", f as DynFF<'_>),
Self::QualifyingResults(f) => ("/qualifying", f as DynFF<'_>),
Self::SprintResults(f) => ("/sprint", f as DynFF<'_>),
Self::RaceResults(f) => ("/results", f as DynFF<'_>),
Self::FinishingStatus(f) => ("/status", f as DynFF<'_>),
Self::LapTimes(f) => ("/laps", f as DynFF<'_>),
Self::PitStops(f) => ("/pitstops", f as DynFF<'_>),
#[allow(clippy::missing_panics_doc)]
_ => panic!("Unsupported resource: {self:?}"),
};
let mut filters = filters.to_formatted_pairs();
let found = filters.iter().enumerate().find(|(_, f)| f.0 == resource_key);
let resource = if let Some((idx, _)) = found {
filters.remove(idx)
} else {
(resource_key, String::new())
};
filters.push(resource);
filters
.iter()
.filter(|(key, val)| !val.is_empty() || key == &resource_key)
.fold(String::new(), |mut acc, (key, val)| {
acc.push_str(key);
acc.push_str(val);
acc
})
}
}
trait FiltersFormatter {
fn to_formatted_pairs(&self) -> Vec<(&'static str, String)>;
}
#[must_use]
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct Filters {
pub season: Option<SeasonID>,
pub round: Option<RoundID>,
pub driver_id: Option<DriverID>,
pub constructor_id: Option<ConstructorID>,
pub circuit_id: Option<CircuitID>,
pub qualifying_pos: Option<u32>,
pub grid_pos: Option<u32>,
pub sprint_pos: Option<u32>,
pub finish_pos: Option<u32>,
pub fastest_lap_rank: Option<u32>,
pub finishing_status: Option<StatusID>,
}
impl Filters {
pub const GRID_PIT_LANE: u32 = crate::jolpica::api::GRID_PIT_LANE;
pub const fn new() -> Self {
Self::none()
}
pub const fn none() -> Self {
Self {
season: None,
round: None,
driver_id: None,
constructor_id: None,
circuit_id: None,
qualifying_pos: None,
grid_pos: None,
sprint_pos: None,
finish_pos: None,
fastest_lap_rank: None,
finishing_status: None,
}
}
pub fn season(self, season: SeasonID) -> Self {
Self {
season: Some(season),
..self
}
}
pub fn round(self, round: RoundID) -> Self {
Self {
round: Some(round),
..self
}
}
pub fn driver_id(self, driver_id: DriverID) -> Self {
Self {
driver_id: Some(driver_id),
..self
}
}
pub fn constructor_id(self, constructor_id: ConstructorID) -> Self {
Self {
constructor_id: Some(constructor_id),
..self
}
}
pub fn circuit_id(self, circuit_id: CircuitID) -> Self {
Self {
circuit_id: Some(circuit_id),
..self
}
}
pub fn qualifying_pos(self, qualifying_pos: u32) -> Self {
Self {
qualifying_pos: Some(qualifying_pos),
..self
}
}
pub fn grid_pos(self, grid_pos: u32) -> Self {
Self {
grid_pos: Some(grid_pos),
..self
}
}
pub fn sprint_pos(self, sprint_pos: u32) -> Self {
Self {
sprint_pos: Some(sprint_pos),
..self
}
}
pub fn finish_pos(self, finish_pos: u32) -> Self {
Self {
finish_pos: Some(finish_pos),
..self
}
}
pub fn fastest_lap_rank(self, fastest_lap_rank: u32) -> Self {
Self {
fastest_lap_rank: Some(fastest_lap_rank),
..self
}
}
pub fn finishing_status(self, finishing_status: StatusID) -> Self {
Self {
finishing_status: Some(finishing_status),
..self
}
}
}
impl Default for Filters {
fn default() -> Self {
Self::new()
}
}
#[must_use]
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct LapTimeFilters {
pub season: SeasonID,
pub round: RoundID,
pub lap: Option<u32>,
pub driver_id: Option<DriverID>,
}
impl LapTimeFilters {
pub const fn new(season: SeasonID, round: RoundID) -> Self {
Self {
season,
round,
lap: None,
driver_id: None,
}
}
pub fn lap(self, lap: u32) -> Self {
Self { lap: Some(lap), ..self }
}
pub fn driver_id(self, driver_id: DriverID) -> Self {
Self {
driver_id: Some(driver_id),
..self
}
}
}
#[must_use]
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct PitStopFilters {
pub season: SeasonID,
pub round: RoundID,
pub lap: Option<u32>,
pub driver_id: Option<DriverID>,
pub pit_stop: Option<u32>,
}
impl PitStopFilters {
pub const fn new(season: SeasonID, round: RoundID) -> Self {
Self {
season,
round,
lap: None,
driver_id: None,
pit_stop: None,
}
}
pub fn lap(self, lap: u32) -> Self {
Self { lap: Some(lap), ..self }
}
pub fn driver_id(self, driver_id: DriverID) -> Self {
Self {
driver_id: Some(driver_id),
..self
}
}
pub fn pit_stop(self, pit_stop: u32) -> Self {
Self {
pit_stop: Some(pit_stop),
..self
}
}
}
#[allow(clippy::ref_option)] fn fmt_from_opt<T: std::fmt::Display>(field: &Option<T>) -> String {
field.as_ref().map_or(String::new(), |val| format!("/{val}"))
}
impl FiltersFormatter for Filters {
fn to_formatted_pairs(&self) -> Vec<(&'static str, String)> {
assert!(!(self.round.is_some() && self.season.is_none()));
Vec::from([
("", fmt_from_opt(&self.season)),
("", fmt_from_opt(&self.round)),
("/drivers", fmt_from_opt(&self.driver_id)),
("/constructors", fmt_from_opt(&self.constructor_id)),
("/circuits", fmt_from_opt(&self.circuit_id)),
("/qualifying", fmt_from_opt(&self.qualifying_pos)),
("/grid", fmt_from_opt(&self.grid_pos)),
("/sprint", fmt_from_opt(&self.sprint_pos)),
("/results", fmt_from_opt(&self.finish_pos)),
("/fastest", fmt_from_opt(&self.fastest_lap_rank)),
("/status", fmt_from_opt(&self.finishing_status)),
])
}
}
impl FiltersFormatter for LapTimeFilters {
fn to_formatted_pairs(&self) -> Vec<(&'static str, String)> {
Vec::from([
("", fmt_from_opt(&Some(self.season))),
("", fmt_from_opt(&Some(self.round))),
("/laps", fmt_from_opt(&self.lap)),
("/drivers", fmt_from_opt(&self.driver_id)),
])
}
}
impl FiltersFormatter for PitStopFilters {
fn to_formatted_pairs(&self) -> Vec<(&'static str, String)> {
Vec::from([
("", fmt_from_opt(&Some(self.season))),
("", fmt_from_opt(&Some(self.round))),
("/laps", fmt_from_opt(&self.lap)),
("/drivers", fmt_from_opt(&self.driver_id)),
("/pitstops", fmt_from_opt(&self.pit_stop)),
])
}
}
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
pub struct Page {
limit: u32,
offset: u32,
}
impl Page {
pub fn with(limit: u32, offset: u32) -> Self {
assert!(limit <= JOLPICA_API_PAGINATION.max_limit);
Self { limit, offset }
}
pub fn with_offset(offset: u32) -> Self {
Self::with(JOLPICA_API_PAGINATION.default_limit, offset)
}
pub fn with_limit(limit: u32) -> Self {
Self::with(limit, JOLPICA_API_PAGINATION.default_offset)
}
pub fn with_max_limit() -> Self {
Self::with_limit(JOLPICA_API_PAGINATION.max_limit)
}
pub const fn limit(&self) -> u32 {
self.limit
}
pub const fn offset(&self) -> u32 {
self.offset
}
#[must_use]
pub const fn next(&self) -> Self {
Self {
offset: self.offset + self.limit,
..*self
}
}
}
impl Default for Page {
fn default() -> Self {
Self::with(JOLPICA_API_PAGINATION.default_limit, JOLPICA_API_PAGINATION.default_offset)
}
}
impl From<Pagination> for Page {
fn from(pagination: Pagination) -> Self {
Self::with(pagination.limit, pagination.offset)
}
}
#[cfg(test)]
mod tests {
use crate::tests::asserts::*;
use shadow_asserts::assert_eq;
use super::*;
#[test]
fn resource_jolpica_base_url() {
assert_eq!(crate::jolpica::api::JOLPICA_API_BASE_URL, "https://api.jolpi.ca/ergast/f1")
}
fn url(tail: &str) -> Url {
Url::parse(&format!("{}{}", crate::jolpica::api::JOLPICA_API_BASE_URL, tail)).unwrap()
}
#[test]
fn resource_to_url_no_filters() {
assert_eq!(Resource::SeasonList(Filters::none()).to_url(), url("/seasons.json"));
assert_eq!(Resource::DriverInfo(Filters::none()).to_url(), url("/drivers.json"));
assert_eq!(Resource::ConstructorInfo(Filters::none()).to_url(), url("/constructors.json"));
assert_eq!(Resource::CircuitInfo(Filters::none()).to_url(), url("/circuits.json"));
assert_eq!(Resource::RaceSchedule(Filters::none()).to_url(), url("/races.json"));
assert_eq!(Resource::QualifyingResults(Filters::none()).to_url(), url("/qualifying.json"));
assert_eq!(Resource::SprintResults(Filters::none()).to_url(), url("/sprint.json"));
assert_eq!(Resource::RaceResults(Filters::none()).to_url(), url("/results.json"));
assert_eq!(Resource::FinishingStatus(Filters::none()).to_url(), url("/status.json"));
}
#[test]
fn resource_to_url_resource_filter() {
assert_eq!(
Resource::DriverInfo(Filters {
driver_id: Some("leclerc".into()),
..Filters::none()
})
.to_url(),
url("/drivers/leclerc.json")
);
assert_eq!(
Resource::QualifyingResults(Filters {
qualifying_pos: Some(1),
..Filters::none()
})
.to_url(),
url("/qualifying/1.json")
);
assert_eq!(
Resource::SprintResults(Filters {
sprint_pos: Some(1),
..Filters::none()
})
.to_url(),
url("/sprint/1.json")
);
assert_eq!(
Resource::RaceResults(Filters {
finish_pos: Some(1),
..Filters::none()
})
.to_url(),
url("/results/1.json")
);
assert_eq!(
Resource::FinishingStatus(Filters {
finishing_status: Some(1),
..Filters::none()
})
.to_url(),
url("/status/1.json")
);
}
#[test]
fn resource_to_url_non_resource_filters() {
assert_eq!(
Resource::SeasonList(Filters {
driver_id: Some("leclerc".into()),
..Filters::none()
})
.to_url(),
url("/drivers/leclerc/seasons.json")
);
assert_eq!(
Resource::DriverInfo(Filters {
constructor_id: Some("ferrari".into()),
circuit_id: Some("spa".into()),
qualifying_pos: Some(1),
..Filters::none()
})
.to_url(),
url("/constructors/ferrari/circuits/spa/qualifying/1/drivers.json")
);
}
#[test]
fn resource_to_url_mixed_filters() {
assert_eq!(
Resource::DriverInfo(Filters {
driver_id: Some("leclerc".into()),
constructor_id: Some("ferrari".into()),
circuit_id: Some("spa".into()),
qualifying_pos: Some(1),
..Filters::none()
})
.to_url(),
url("/constructors/ferrari/circuits/spa/qualifying/1/drivers/leclerc.json")
);
}
#[test]
fn resource_to_url_season_round_filters() {
assert_eq!(
Resource::DriverInfo(Filters {
season: Some(2023),
..Filters::none()
})
.to_url(),
url("/2023/drivers.json")
);
assert_eq!(
Resource::SeasonList(Filters {
season: Some(2023),
round: Some(1),
..Filters::none()
})
.to_url(),
url("/2023/1/seasons.json")
);
assert_eq!(
Resource::RaceSchedule(Filters {
season: Some(2023),
round: Some(4),
..Filters::none()
})
.to_url(),
url("/2023/4/races.json")
);
}
#[test]
fn resource_to_url_with_page() {
assert_eq!(
Resource::DriverInfo(Filters::none()).to_url_with(Page::with(10, 5)),
url("/drivers.json?limit=10&offset=5")
);
assert_eq!(
Resource::DriverInfo(Filters::none()).to_url_with(Page::with_offset(10)),
url("/drivers.json?limit=30&offset=10")
);
assert_eq!(
Resource::DriverInfo(Filters::none()).to_url_with(Page::with_max_limit()),
url("/drivers.json?limit=100&offset=0")
);
}
#[test]
#[should_panic]
fn resource_to_url_round_without_season_filter_panics() {
let _unused = Resource::RaceSchedule(Filters {
round: Some(1),
..Filters::none()
})
.to_url();
}
#[test]
fn resource_lap_times_to_url() {
assert_eq!(Resource::LapTimes(LapTimeFilters::new(2023, 4)).to_url(), url("/2023/4/laps.json"));
assert_eq!(
Resource::LapTimes(LapTimeFilters {
season: 2023,
round: 4,
lap: Some(1),
driver_id: Some("alonso".into())
})
.to_url(),
url("/2023/4/drivers/alonso/laps/1.json")
);
}
#[test]
fn resource_pit_stops_to_url() {
assert_eq!(Resource::PitStops(PitStopFilters::new(2023, 4)).to_url(), url("/2023/4/pitstops.json"));
assert_eq!(
Resource::PitStops(PitStopFilters {
season: 2023,
round: 4,
lap: Some(1),
driver_id: Some("alonso".into()),
pit_stop: Some(1),
})
.to_url(),
url("/2023/4/laps/1/drivers/alonso/pitstops/1.json")
);
}
#[test]
fn resource_to_url_with_base_and_opt_page() {
assert_eq!(
Resource::DriverInfo(Filters::none()).to_url_with_base_and_opt_page("https://example.com/api", None),
Url::parse("https://example.com/api/drivers.json").unwrap()
);
assert_eq!(
Resource::DriverInfo(Filters::none())
.to_url_with_base_and_opt_page("https://example.com/api", Some(Page::with(10, 5))),
Url::parse("https://example.com/api/drivers.json?limit=10&offset=5").unwrap()
);
}
#[test]
fn resource_to_endpoint() {
assert_eq!(
Resource::DriverInfo(Filters {
constructor_id: Some("ferrari".into()),
circuit_id: Some("spa".into()),
qualifying_pos: Some(1),
..Filters::none()
})
.to_endpoint(),
"/constructors/ferrari/circuits/spa/qualifying/1/drivers"
);
}
#[test]
fn filters() {
let filters = Filters::none();
assert_true!(
filters.season.is_none()
&& filters.round.is_none()
&& filters.driver_id.is_none()
&& filters.constructor_id.is_none()
&& filters.circuit_id.is_none()
&& filters.qualifying_pos.is_none()
&& filters.grid_pos.is_none()
&& filters.sprint_pos.is_none()
&& filters.finish_pos.is_none()
&& filters.fastest_lap_rank.is_none()
&& filters.finishing_status.is_none()
);
let filters = Filters {
driver_id: Some("alonso".into()),
circuit_id: Some("spa".into()),
..Filters::none()
};
assert_eq!(filters.driver_id, Some("alonso".into()));
assert_eq!(filters.circuit_id, Some("spa".into()));
assert_true!(
filters.season.is_none()
&& filters.round.is_none()
&& filters.constructor_id.is_none()
&& filters.qualifying_pos.is_none()
&& filters.grid_pos.is_none()
&& filters.sprint_pos.is_none()
&& filters.finish_pos.is_none()
&& filters.fastest_lap_rank.is_none()
&& filters.finishing_status.is_none()
);
let filters = Filters::new().driver_id("alonso".into()).circuit_id("spa".into());
assert_eq!(filters.driver_id, Some("alonso".into()));
assert_eq!(filters.circuit_id, Some("spa".into()));
assert_true!(
filters.season.is_none()
&& filters.round.is_none()
&& filters.constructor_id.is_none()
&& filters.qualifying_pos.is_none()
&& filters.grid_pos.is_none()
&& filters.sprint_pos.is_none()
&& filters.finish_pos.is_none()
&& filters.fastest_lap_rank.is_none()
&& filters.finishing_status.is_none()
);
assert_eq!(
Filters {
season: Some(2023),
round: Some(1),
driver_id: Some("alonso".into()),
constructor_id: Some("aston_martin".into()),
circuit_id: Some("baku".into()),
qualifying_pos: Some(6),
grid_pos: Some(6),
sprint_pos: Some(1),
finish_pos: Some(4),
fastest_lap_rank: Some(3),
finishing_status: Some(1),
},
Filters::new()
.season(2023)
.round(1)
.driver_id("alonso".into())
.constructor_id("aston_martin".into())
.circuit_id("baku".into())
.qualifying_pos(6)
.grid_pos(6)
.sprint_pos(1)
.finish_pos(4)
.fastest_lap_rank(3)
.finishing_status(1)
);
}
#[test]
fn lap_time_filters() {
let filters = LapTimeFilters::new(2023, 4);
assert_eq!(filters.season, 2023);
assert_eq!(filters.round, 4);
assert_true!(filters.lap.is_none() && filters.driver_id.is_none());
let filters = LapTimeFilters {
lap: Some(1),
..LapTimeFilters::new(2023, 4)
};
assert_eq!(filters.season, 2023);
assert_eq!(filters.round, 4);
assert_eq!(filters.lap, Some(1));
assert_true!(filters.driver_id.is_none());
assert_eq!(
LapTimeFilters {
season: 2023,
round: 4,
lap: Some(1),
driver_id: Some("alonso".into()),
},
LapTimeFilters::new(2023, 4).lap(1).driver_id("alonso".into())
);
}
#[test]
fn pit_stop_filters() {
let filters = PitStopFilters::new(2023, 4);
assert_eq!(filters.season, 2023);
assert_eq!(filters.round, 4);
assert_true!(filters.lap.is_none() && filters.driver_id.is_none() && filters.pit_stop.is_none());
let filters = PitStopFilters {
lap: Some(1),
..PitStopFilters::new(2023, 4)
};
assert_eq!(filters.season, 2023);
assert_eq!(filters.round, 4);
assert_eq!(filters.lap, Some(1));
assert_true!(filters.driver_id.is_none() && filters.pit_stop.is_none());
assert_eq!(
PitStopFilters {
season: 2023,
round: 4,
lap: Some(1),
driver_id: Some("alonso".into()),
pit_stop: Some(1),
},
PitStopFilters::new(2023, 4)
.lap(1)
.driver_id("alonso".into())
.pit_stop(1)
);
}
#[test]
fn filters_to_formatted_pairs_lifetime() {
let &mut formatted_pairs;
{
formatted_pairs = Filters::none().to_formatted_pairs();
}
assert_false!(formatted_pairs.is_empty());
assert_eq!(formatted_pairs[0].0, "");
}
#[test]
fn page_construction() {
assert_eq!(Page::with(20, 5), Page { limit: 20, offset: 5 });
assert_eq!(Page::with_limit(20), Page { limit: 20, offset: 0 });
assert_eq!(
Page::with_offset(5),
Page {
limit: JOLPICA_API_PAGINATION.default_limit,
offset: 5
}
);
assert_eq!(
Page::with_max_limit(),
Page {
limit: JOLPICA_API_PAGINATION.max_limit,
offset: JOLPICA_API_PAGINATION.default_offset
}
);
assert_eq!(
Page::default(),
Page {
limit: JOLPICA_API_PAGINATION.default_limit,
offset: JOLPICA_API_PAGINATION.default_offset
}
);
}
#[test]
#[should_panic]
fn page_construction_panics() {
let _ = Page::with_limit(2000);
}
#[test]
fn page_next() {
assert_eq!(Page::with(30, 0).next(), Page::with(30, 30));
assert_eq!(Page::with(30, 10).next(), Page::with(30, 40));
}
#[test]
fn page_from_pagination() {
assert_eq!(
Page::from(Pagination {
limit: 30,
offset: 10,
total: 100
}),
Page::with(30, 10)
);
}
}