//! Endpoints for the `PandaScore` API. use std::{ collections::{HashMap, HashSet}, ops::{Deref, DerefMut}, sync::OnceLock, }; use compact_str::{format_compact, CompactString, CompactStringExt, ToCompactString}; use linkify::{Link, LinkFinder, LinkKind}; use regex::Regex; use reqwest::header::{AsHeaderName, LINK}; use serde::de::DeserializeOwned; pub mod all; pub mod lol; pub mod rl; const BASE_URL: &str = "https://api.pandascore.co"; mod sealed { use std::future::Future; use crate::endpoint::EndpointError; pub trait Sealed { type Response; fn to_request(self) -> Result; fn from_response( response: reqwest::Response, ) -> impl Future> + Send; } } /// Represents an endpoint in the `PandaScore` API. /// /// This trait is sealed and can't be implemented outside this crate. pub trait Endpoint: sealed::Sealed {} impl Endpoint for T {} pub trait PaginatedEndpoint: Endpoint { type Item; #[must_use] fn with_options(self, options: CollectionOptions) -> Self; } async fn deserialize(response: reqwest::Response) -> Result { let body = response.bytes().await?; let mut jd = serde_json::Deserializer::from_slice(body.as_ref()); Ok(serde_path_to_error::deserialize(&mut jd)?) } /// Represents an error that occurred while interacting with an endpoint. #[derive(Debug, thiserror::Error)] pub enum EndpointError { #[error(transparent)] Reqwest(#[from] reqwest::Error), #[error(transparent)] Serde(#[from] serde_path_to_error::Error), #[error(transparent)] UrlParse(#[from] url::ParseError), #[error("Failed to convert header to string: {0}")] ToStr(#[from] reqwest::header::ToStrError), #[error("Failed to parse integer: {0}")] InvalidInt(#[from] std::num::ParseIntError), } /// Options for filtering, searching, sorting, and paginating a collection. #[derive(Debug, Clone, Eq, PartialEq, Default)] pub struct CollectionOptions { /// filters: HashMap>, /// search: HashMap, /// range: HashMap, /// sort: HashSet, /// page: Option, /// per_page: Option, } impl CollectionOptions { /// Creates a new empty set of collection options. #[must_use] pub fn new() -> Self { Self::default() } /// Adds a filter to the collection options. /// If the filter already exists, the value is appended to the existing values. /// /// #[must_use] pub fn filter( mut self, key: impl Into, value: impl Into, ) -> Self { self.filters .entry(key.into()) .or_default() .push(value.into()); self } /// Adds a search to the collection options. /// If the search already exists, the value is overwritten. /// /// #[must_use] pub fn search( mut self, key: impl Into, value: impl Into, ) -> Self { self.search.insert(key.into(), value.into()); self } /// Adds a range to the collection options. /// If the range already exists, the value is overwritten. /// /// #[must_use] pub fn range(mut self, key: impl Into, start: i64, end: i64) -> Self { self.range.insert(key.into(), (start, end)); self } /// Adds a sort to the collection options. /// If a sort already exists, the value is appended to the existing values as a secondary sort. /// /// #[must_use] pub fn sort(mut self, key: impl Into) -> Self { self.sort.insert(key.into()); self } /// Sets the page number for the collection options. /// /// #[must_use] pub const fn page(mut self, page: u32) -> Self { self.page = Some(page); self } /// Sets the number of items per page for the collection options. /// /// #[must_use] pub const fn per_page(mut self, per_page: u32) -> Self { self.per_page = Some(per_page); self } fn add_params(self, url: &mut url::Url) { let mut query = url.query_pairs_mut(); for (key, values) in self.filters { let key = format_compact!("filter[{}]", key); let value = values.join_compact(","); query.append_pair(&key, &value); } for (key, value) in self.search { let key = format_compact!("search[{}]", key); query.append_pair(&key, &value); } for (key, (start, end)) in self.range { let key = format_compact!("range[{}]", key); let value = format!("{start},{end}"); query.append_pair(&key, &value); } if !self.sort.is_empty() { let value = self.sort.join_compact(","); query.append_pair("sort", &value); } if let Some(page) = self.page { // query.append_pair("page[number]", &page.to_compact_string()); query.append_pair("page", &page.to_compact_string()); } if let Some(per_page) = self.per_page { // query.append_pair("page[size]", &per_page.to_compact_string()); query.append_pair("per_page", &per_page.to_compact_string()); } } fn from_url(url: &str) -> Result { let url = url::Url::parse(url)?; let query = url.query_pairs(); let mut ret = Self::default(); for (key, value) in query { let Some(captures) = get_key_regex().captures(&key) else { continue; }; match &captures[1] { "filter" => { let Some(key) = captures.get(3) else { continue; }; let key = key.as_str().to_compact_string(); let value = value.split(',').map(CompactString::from).collect(); ret.filters.insert(key, value); } "search" => { let Some(key) = captures.get(3) else { continue; }; ret.search .insert(key.as_str().to_compact_string(), value.to_compact_string()); } "range" => { let Some(key) = captures.get(3) else { continue; }; let key = key.as_str().to_compact_string(); let Some((start, end)) = value.split_once(',') else { continue; }; let start = start.parse()?; let end = end.parse()?; ret.range.insert(key, (start, end)); } "sort" => ret.sort = value.split(',').map(CompactString::from).collect(), "page" => { if let Some(tp) = captures.get(3) { match tp.as_str() { "number" => ret.page = Some(value.parse()?), "size" => ret.per_page = Some(value.parse()?), _ => continue, } } else { ret.page = Some(value.parse()?); } } "per_page" => ret.per_page = Some(value.parse()?), _ => continue, } } Ok(ret) } } static KEY_REGEX: OnceLock = OnceLock::new(); fn get_key_regex() -> &'static Regex { // Matches "filter[...]", "search[...]", "range[...]", "sort" // https://regex101.com/r/3snY41/1 KEY_REGEX.get_or_init(|| Regex::new(r"([a-z_]+)(\[(.+)])?").unwrap()) } /// Represents a response from a collection endpoint. /// Contains the results, total number of results, /// and pagination options for retrieving more results. #[derive(Debug, Clone, Eq, PartialEq)] pub struct ListResponse { pub results: Vec, pub total: u64, pub next: Option, pub prev: Option, } static LINK_REL_REGEX: OnceLock = OnceLock::new(); fn get_link_rel_regex() -> &'static Regex { LINK_REL_REGEX.get_or_init(|| Regex::new(r#"rel="([a-z]+)""#).unwrap()) } impl ListResponse { async fn from_response(response: reqwest::Response) -> Result { let response = response.error_for_status()?; let total = parse_header_int(&response, "X-Total")?.unwrap_or(0); let link_str = response .headers() .get(LINK) .map(|v| v.to_str()) .transpose()?; let Some(link_str) = link_str else { return Ok(Self { results: deserialize(response).await?, total, next: None, prev: None, }); }; let mut next = None; let mut prev = None; // Format: // ; rel="x", // where x is first, last, next, or prev let mut finder = LinkFinder::new(); finder.kinds(&[LinkKind::Url]); let links = finder.links(link_str).collect::>(); for (i, link) in links.iter().enumerate() { // Find `rel=` attribute between this link and the next let substr = &link_str [link.start()..links.get(i + 1).map_or_else(|| link_str.len(), Link::start)]; let Some(captures) = get_link_rel_regex().captures(substr) else { // No `rel=` attribute found continue; }; match &captures[1] { "next" => next = Some(CollectionOptions::from_url(link.as_str())?), "prev" => prev = Some(CollectionOptions::from_url(link.as_str())?), _ => continue, } } Ok(Self { results: deserialize(response).await?, total, next, prev, }) } } impl Deref for ListResponse { type Target = Vec; fn deref(&self) -> &Self::Target { &self.results } } impl DerefMut for ListResponse { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.results } } fn parse_header_int( response: &reqwest::Response, header: K, ) -> Result, EndpointError> where K: AsHeaderName, T: std::str::FromStr, { Ok(response .headers() .get(header) .map(|v| v.to_str()) .transpose()? .map(str::parse) .transpose()?) } macro_rules! game_endpoints { ($endpoint:literal) => { pub mod leagues { $crate::endpoint::list_endpoint!( ListLeagues(concat!("/", $endpoint, "/leagues")) => $crate::model::league::League ); } pub mod matches { $crate::endpoint::multi_list_endpoint!( ListMatches(concat!("/", $endpoint, "/matches")) => $crate::model::matches::Match ); } pub mod players { $crate::endpoint::list_endpoint!( ListPlayers(concat!("/", $endpoint, "/players")) => $crate::model::player::Player ); } pub mod series { $crate::endpoint::multi_list_endpoint!( ListSeries(concat!("/", $endpoint, "/series")) => $crate::model::series::Series ); } pub mod teams { $crate::endpoint::list_endpoint!( ListTeams(concat!("/", $endpoint, "/teams")) => $crate::model::team::Team ); } pub mod tournaments { $crate::endpoint::multi_list_endpoint!( ListTournaments(concat!("/", $endpoint, "/tournaments")) => $crate::model::tournament::Tournament ); } }; } pub(crate) use game_endpoints; macro_rules! get_endpoint { ($name:ident($path:expr) => $response:ty) => { #[derive(Debug, Clone, Eq, PartialEq)] pub struct $name<'a>(pub $crate::model::Identifier<'a>); impl<'a> $crate::endpoint::sealed::Sealed for $name<'a> { type Response = $response; fn to_request( self, ) -> std::result::Result<::reqwest::Request, $crate::endpoint::EndpointError> { let url = ::url::Url::parse(&format!( concat!("{}", $path, "/{}"), $crate::endpoint::BASE_URL, self.0 ))?; Ok(::reqwest::Request::new(::reqwest::Method::GET, url)) } async fn from_response( response: ::reqwest::Response, ) -> ::std::result::Result { $crate::endpoint::deserialize(response.error_for_status()?).await } } impl<'a, T> ::std::convert::From for $name<'a> where T: Into<$crate::model::Identifier<'a>>, { fn from(id: T) -> Self { Self(id.into()) } } }; } pub(crate) use get_endpoint; macro_rules! list_endpoint { ($name:ident($path:expr) => $response:ty) => { #[derive(Debug, Clone, Eq, PartialEq, Default)] pub struct $name(pub $crate::endpoint::CollectionOptions); impl $crate::endpoint::sealed::Sealed for $name { type Response = $crate::endpoint::ListResponse<$response>; fn to_request( self, ) -> std::result::Result<::reqwest::Request, $crate::endpoint::EndpointError> { let mut url = ::url::Url::parse(&format!(concat!("{}", $path), $crate::endpoint::BASE_URL))?; self.0.add_params(&mut url); Ok(::reqwest::Request::new(::reqwest::Method::GET, url)) } fn from_response( response: ::reqwest::Response, ) -> impl ::std::future::Future< Output = ::std::result::Result, > + Send { $crate::endpoint::ListResponse::from_response(response) } } impl $crate::endpoint::PaginatedEndpoint for $name { type Item = $response; fn with_options(self, options: $crate::endpoint::CollectionOptions) -> Self { Self(options) } } }; } pub(crate) use list_endpoint; macro_rules! multi_list_endpoint { ($name:ident($path:expr) => $response:ty) => { #[derive(Debug, Clone, Eq, PartialEq, Default, ::bon::Builder)] pub struct $name { pub status: ::std::option::Option<$crate::model::EventStatus>, #[builder(default)] pub options: $crate::endpoint::CollectionOptions, } impl $crate::endpoint::sealed::Sealed for $name { type Response = $crate::endpoint::ListResponse<$response>; fn to_request( self, ) -> std::result::Result<::reqwest::Request, $crate::endpoint::EndpointError> { let mut url = ::url::Url::parse(&format!( concat!("{}", $path, "/"), $crate::endpoint::BASE_URL ))?; self.options.add_params(&mut url); if let Some(status) = self.status { url = url.join(status.as_str())?; } Ok(::reqwest::Request::new(::reqwest::Method::GET, url)) } fn from_response( response: ::reqwest::Response, ) -> impl ::std::future::Future< Output = ::std::result::Result, > + Send { $crate::endpoint::ListResponse::from_response(response) } } impl $crate::endpoint::PaginatedEndpoint for $name { type Item = $response; fn with_options(self, options: $crate::endpoint::CollectionOptions) -> Self { Self { options, ..self } } } }; } pub(crate) use multi_list_endpoint; #[cfg(test)] mod tests { use url::Url; use super::*; #[test] fn test_collection_options_add_params() { let mut url = Url::parse("https://example.com").unwrap(); let options = CollectionOptions::new() .filter("foo", "bar") .filter("foo", "baz") .filter("qux", "quux") .search("qux", "quux") .range("corge", 1, 5) .sort("grault") .sort("-garply") .page(3) .per_page(4); options.clone().add_params(&mut url); assert!(url.query().is_some()); let options2 = CollectionOptions::from_url(url.as_str()).unwrap(); assert_eq!(options, options2); } }