pandascore/endpoint/
mod.rs

1//! Endpoints for the `PandaScore` API.
2
3use std::{
4    collections::{HashMap, HashSet},
5    ops::{Deref, DerefMut},
6    sync::OnceLock,
7};
8
9use compact_str::{format_compact, CompactString, CompactStringExt, ToCompactString};
10use linkify::{Link, LinkFinder, LinkKind};
11use regex::Regex;
12use reqwest::header::{AsHeaderName, LINK};
13use serde::de::DeserializeOwned;
14
15pub mod all;
16pub mod lol;
17pub mod rl;
18
19const BASE_URL: &str = "https://api.pandascore.co";
20
21mod sealed {
22    use std::future::Future;
23
24    use crate::endpoint::EndpointError;
25
26    pub trait Sealed {
27        type Response;
28
29        fn to_request(self) -> Result<reqwest::Request, EndpointError>;
30        fn from_response(
31            response: reqwest::Response,
32        ) -> impl Future<Output = Result<Self::Response, EndpointError>> + Send;
33    }
34}
35
36/// Represents an endpoint in the `PandaScore` API.
37///
38/// This trait is sealed and can't be implemented outside this crate.
39pub trait Endpoint: sealed::Sealed {}
40
41impl<T: sealed::Sealed> Endpoint for T {}
42
43pub trait PaginatedEndpoint: Endpoint {
44    type Item;
45
46    #[must_use]
47    fn with_options(self, options: CollectionOptions) -> Self;
48}
49
50async fn deserialize<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, EndpointError> {
51    let body = response.bytes().await?;
52    let mut jd = serde_json::Deserializer::from_slice(body.as_ref());
53    Ok(serde_path_to_error::deserialize(&mut jd)?)
54}
55
56/// Represents an error that occurred while interacting with an endpoint.
57#[derive(Debug, thiserror::Error)]
58pub enum EndpointError {
59    #[error(transparent)]
60    Reqwest(#[from] reqwest::Error),
61    #[error(transparent)]
62    Serde(#[from] serde_path_to_error::Error<serde_json::Error>),
63    #[error(transparent)]
64    UrlParse(#[from] url::ParseError),
65    #[error("Failed to convert header to string: {0}")]
66    ToStr(#[from] reqwest::header::ToStrError),
67    #[error("Failed to parse integer: {0}")]
68    InvalidInt(#[from] std::num::ParseIntError),
69}
70
71/// Options for filtering, searching, sorting, and paginating a collection.
72#[derive(Debug, Clone, Eq, PartialEq, Default)]
73pub struct CollectionOptions {
74    /// <https://developers.pandascore.co/docs/filtering-and-sorting#filter>
75    filters: HashMap<CompactString, Vec<CompactString>>,
76    /// <https://developers.pandascore.co/docs/filtering-and-sorting#search>
77    search: HashMap<CompactString, CompactString>,
78    /// <https://developers.pandascore.co/docs/filtering-and-sorting#range>
79    range: HashMap<CompactString, (i64, i64)>,
80    /// <https://developers.pandascore.co/docs/filtering-and-sorting#sort>
81    sort: HashSet<CompactString>,
82
83    /// <https://developers.pandascore.co/docs/pagination#page-number>
84    page: Option<u32>,
85    /// <https://developers.pandascore.co/docs/pagination#page-size>
86    per_page: Option<u32>,
87}
88
89impl CollectionOptions {
90    /// Creates a new empty set of collection options.
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Adds a filter to the collection options.
97    /// If the filter already exists, the value is appended to the existing values.
98    ///
99    /// <https://developers.pandascore.co/docs/filtering-and-sorting#filter>
100    #[must_use]
101    pub fn filter(
102        mut self,
103        key: impl Into<CompactString>,
104        value: impl Into<CompactString>,
105    ) -> Self {
106        self.filters
107            .entry(key.into())
108            .or_default()
109            .push(value.into());
110        self
111    }
112
113    /// Adds a search to the collection options.
114    /// If the search already exists, the value is overwritten.
115    ///
116    /// <https://developers.pandascore.co/docs/filtering-and-sorting#search>
117    #[must_use]
118    pub fn search(
119        mut self,
120        key: impl Into<CompactString>,
121        value: impl Into<CompactString>,
122    ) -> Self {
123        self.search.insert(key.into(), value.into());
124        self
125    }
126
127    /// Adds a range to the collection options.
128    /// If the range already exists, the value is overwritten.
129    ///
130    /// <https://developers.pandascore.co/docs/filtering-and-sorting#range>
131    #[must_use]
132    pub fn range(mut self, key: impl Into<CompactString>, start: i64, end: i64) -> Self {
133        self.range.insert(key.into(), (start, end));
134        self
135    }
136
137    /// Adds a sort to the collection options.
138    /// If a sort already exists, the value is appended to the existing values as a secondary sort.
139    ///
140    /// <https://developers.pandascore.co/docs/filtering-and-sorting#sort>
141    #[must_use]
142    pub fn sort(mut self, key: impl Into<CompactString>) -> Self {
143        self.sort.insert(key.into());
144        self
145    }
146
147    /// Sets the page number for the collection options.
148    ///
149    /// <https://developers.pandascore.co/docs/pagination#page-number>
150    #[must_use]
151    pub const fn page(mut self, page: u32) -> Self {
152        self.page = Some(page);
153        self
154    }
155
156    /// Sets the number of items per page for the collection options.
157    ///
158    /// <https://developers.pandascore.co/docs/pagination#page-size>
159    #[must_use]
160    pub const fn per_page(mut self, per_page: u32) -> Self {
161        self.per_page = Some(per_page);
162        self
163    }
164
165    fn add_params(self, url: &mut url::Url) {
166        let mut query = url.query_pairs_mut();
167
168        for (key, values) in self.filters {
169            let key = format_compact!("filter[{}]", key);
170            let value = values.join_compact(",");
171            query.append_pair(&key, &value);
172        }
173
174        for (key, value) in self.search {
175            let key = format_compact!("search[{}]", key);
176            query.append_pair(&key, &value);
177        }
178
179        for (key, (start, end)) in self.range {
180            let key = format_compact!("range[{}]", key);
181            let value = format!("{start},{end}");
182            query.append_pair(&key, &value);
183        }
184
185        if !self.sort.is_empty() {
186            let value = self.sort.join_compact(",");
187            query.append_pair("sort", &value);
188        }
189
190        if let Some(page) = self.page {
191            // query.append_pair("page[number]", &page.to_compact_string());
192            query.append_pair("page", &page.to_compact_string());
193        }
194        if let Some(per_page) = self.per_page {
195            // query.append_pair("page[size]", &per_page.to_compact_string());
196            query.append_pair("per_page", &per_page.to_compact_string());
197        }
198    }
199
200    fn from_url(url: &str) -> Result<Self, EndpointError> {
201        let url = url::Url::parse(url)?;
202        let query = url.query_pairs();
203
204        let mut ret = Self::default();
205        for (key, value) in query {
206            let Some(captures) = get_key_regex().captures(&key) else {
207                continue;
208            };
209
210            match &captures[1] {
211                "filter" => {
212                    let Some(key) = captures.get(3) else {
213                        continue;
214                    };
215                    let key = key.as_str().to_compact_string();
216                    let value = value.split(',').map(CompactString::from).collect();
217                    ret.filters.insert(key, value);
218                }
219                "search" => {
220                    let Some(key) = captures.get(3) else {
221                        continue;
222                    };
223                    ret.search
224                        .insert(key.as_str().to_compact_string(), value.to_compact_string());
225                }
226                "range" => {
227                    let Some(key) = captures.get(3) else {
228                        continue;
229                    };
230                    let key = key.as_str().to_compact_string();
231                    let Some((start, end)) = value.split_once(',') else {
232                        continue;
233                    };
234                    let start = start.parse()?;
235                    let end = end.parse()?;
236                    ret.range.insert(key, (start, end));
237                }
238                "sort" => ret.sort = value.split(',').map(CompactString::from).collect(),
239                "page" => {
240                    if let Some(tp) = captures.get(3) {
241                        match tp.as_str() {
242                            "number" => ret.page = Some(value.parse()?),
243                            "size" => ret.per_page = Some(value.parse()?),
244                            _ => continue,
245                        }
246                    } else {
247                        ret.page = Some(value.parse()?);
248                    }
249                }
250                "per_page" => ret.per_page = Some(value.parse()?),
251                _ => continue,
252            }
253        }
254
255        Ok(ret)
256    }
257}
258
259static KEY_REGEX: OnceLock<Regex> = OnceLock::new();
260
261fn get_key_regex() -> &'static Regex {
262    // Matches "filter[...]", "search[...]", "range[...]", "sort"
263    // https://regex101.com/r/3snY41/1
264    KEY_REGEX.get_or_init(|| Regex::new(r"([a-z_]+)(\[(.+)])?").unwrap())
265}
266
267/// Represents a response from a collection endpoint.
268/// Contains the results, total number of results,
269/// and pagination options for retrieving more results.
270#[derive(Debug, Clone, Eq, PartialEq)]
271pub struct ListResponse<T> {
272    pub results: Vec<T>,
273    pub total: u64,
274    pub next: Option<CollectionOptions>,
275    pub prev: Option<CollectionOptions>,
276}
277
278static LINK_REL_REGEX: OnceLock<Regex> = OnceLock::new();
279
280fn get_link_rel_regex() -> &'static Regex {
281    LINK_REL_REGEX.get_or_init(|| Regex::new(r#"rel="([a-z]+)""#).unwrap())
282}
283
284impl<T: DeserializeOwned> ListResponse<T> {
285    async fn from_response(response: reqwest::Response) -> Result<Self, EndpointError> {
286        let response = response.error_for_status()?;
287
288        let total = parse_header_int(&response, "X-Total")?.unwrap_or(0);
289        let link_str = response
290            .headers()
291            .get(LINK)
292            .map(|v| v.to_str())
293            .transpose()?;
294
295        let Some(link_str) = link_str else {
296            return Ok(Self {
297                results: deserialize(response).await?,
298                total,
299                next: None,
300                prev: None,
301            });
302        };
303
304        let mut next = None;
305        let mut prev = None;
306
307        // Format:
308        // <https://some.url>; rel="x",
309        // where x is first, last, next, or prev
310        let mut finder = LinkFinder::new();
311        finder.kinds(&[LinkKind::Url]);
312        let links = finder.links(link_str).collect::<Vec<Link>>();
313
314        for (i, link) in links.iter().enumerate() {
315            // Find `rel=` attribute between this link and the next
316            let substr = &link_str
317                [link.start()..links.get(i + 1).map_or_else(|| link_str.len(), Link::start)];
318
319            let Some(captures) = get_link_rel_regex().captures(substr) else {
320                // No `rel=` attribute found
321                continue;
322            };
323            match &captures[1] {
324                "next" => next = Some(CollectionOptions::from_url(link.as_str())?),
325                "prev" => prev = Some(CollectionOptions::from_url(link.as_str())?),
326                _ => continue,
327            }
328        }
329
330        Ok(Self {
331            results: deserialize(response).await?,
332            total,
333            next,
334            prev,
335        })
336    }
337}
338
339impl<T> Deref for ListResponse<T> {
340    type Target = Vec<T>;
341
342    fn deref(&self) -> &Self::Target {
343        &self.results
344    }
345}
346
347impl<T> DerefMut for ListResponse<T> {
348    fn deref_mut(&mut self) -> &mut Self::Target {
349        &mut self.results
350    }
351}
352
353fn parse_header_int<K, T>(
354    response: &reqwest::Response,
355    header: K,
356) -> Result<Option<T>, EndpointError>
357where
358    K: AsHeaderName,
359    T: std::str::FromStr<Err = std::num::ParseIntError>,
360{
361    Ok(response
362        .headers()
363        .get(header)
364        .map(|v| v.to_str())
365        .transpose()?
366        .map(str::parse)
367        .transpose()?)
368}
369
370macro_rules! game_endpoints {
371    ($endpoint:literal) => {
372        pub mod leagues {
373            $crate::endpoint::list_endpoint!(
374                ListLeagues(concat!("/", $endpoint, "/leagues")) => $crate::model::league::League
375            );
376        }
377        pub mod matches {
378            $crate::endpoint::multi_list_endpoint!(
379                ListMatches(concat!("/", $endpoint, "/matches")) => $crate::model::matches::Match
380            );
381        }
382        pub mod players {
383            $crate::endpoint::list_endpoint!(
384                ListPlayers(concat!("/", $endpoint, "/players")) => $crate::model::player::Player
385            );
386        }
387        pub mod series {
388            $crate::endpoint::multi_list_endpoint!(
389                ListSeries(concat!("/", $endpoint, "/series")) => $crate::model::series::Series
390            );
391        }
392        pub mod teams {
393            $crate::endpoint::list_endpoint!(
394                ListTeams(concat!("/", $endpoint, "/teams")) => $crate::model::team::Team
395            );
396        }
397        pub mod tournaments {
398            $crate::endpoint::multi_list_endpoint!(
399                ListTournaments(concat!("/", $endpoint, "/tournaments")) => $crate::model::tournament::Tournament
400            );
401        }
402    };
403}
404pub(crate) use game_endpoints;
405
406macro_rules! get_endpoint {
407    ($name:ident($path:expr) => $response:ty) => {
408        #[derive(Debug, Clone, Eq, PartialEq)]
409        pub struct $name<'a>(pub $crate::model::Identifier<'a>);
410
411        impl<'a> $crate::endpoint::sealed::Sealed for $name<'a> {
412            type Response = $response;
413
414            fn to_request(
415                self,
416            ) -> std::result::Result<::reqwest::Request, $crate::endpoint::EndpointError> {
417                let url = ::url::Url::parse(&format!(
418                    concat!("{}", $path, "/{}"),
419                    $crate::endpoint::BASE_URL,
420                    self.0
421                ))?;
422                Ok(::reqwest::Request::new(::reqwest::Method::GET, url))
423            }
424
425            async fn from_response(
426                response: ::reqwest::Response,
427            ) -> ::std::result::Result<Self::Response, $crate::endpoint::EndpointError> {
428                $crate::endpoint::deserialize(response.error_for_status()?).await
429            }
430        }
431
432        impl<'a, T> ::std::convert::From<T> for $name<'a>
433        where
434            T: Into<$crate::model::Identifier<'a>>,
435        {
436            fn from(id: T) -> Self {
437                Self(id.into())
438            }
439        }
440    };
441}
442pub(crate) use get_endpoint;
443
444macro_rules! list_endpoint {
445    ($name:ident($path:expr) => $response:ty) => {
446        #[derive(Debug, Clone, Eq, PartialEq, Default)]
447        pub struct $name(pub $crate::endpoint::CollectionOptions);
448
449        impl $crate::endpoint::sealed::Sealed for $name {
450            type Response = $crate::endpoint::ListResponse<$response>;
451
452            fn to_request(
453                self,
454            ) -> std::result::Result<::reqwest::Request, $crate::endpoint::EndpointError> {
455                let mut url =
456                    ::url::Url::parse(&format!(concat!("{}", $path), $crate::endpoint::BASE_URL))?;
457                self.0.add_params(&mut url);
458                Ok(::reqwest::Request::new(::reqwest::Method::GET, url))
459            }
460
461            fn from_response(
462                response: ::reqwest::Response,
463            ) -> impl ::std::future::Future<
464                Output = ::std::result::Result<Self::Response, $crate::endpoint::EndpointError>,
465            > + Send {
466                $crate::endpoint::ListResponse::from_response(response)
467            }
468        }
469
470        impl $crate::endpoint::PaginatedEndpoint for $name {
471            type Item = $response;
472
473            fn with_options(self, options: $crate::endpoint::CollectionOptions) -> Self {
474                Self(options)
475            }
476        }
477    };
478}
479pub(crate) use list_endpoint;
480
481macro_rules! multi_list_endpoint {
482    ($name:ident($path:expr) => $response:ty) => {
483        #[derive(Debug, Clone, Eq, PartialEq, Default, ::bon::Builder)]
484        pub struct $name {
485            pub status: ::std::option::Option<$crate::model::EventStatus>,
486            #[builder(default)]
487            pub options: $crate::endpoint::CollectionOptions,
488        }
489
490        impl $crate::endpoint::sealed::Sealed for $name {
491            type Response = $crate::endpoint::ListResponse<$response>;
492
493            fn to_request(
494                self,
495            ) -> std::result::Result<::reqwest::Request, $crate::endpoint::EndpointError> {
496                let mut url = ::url::Url::parse(&format!(
497                    concat!("{}", $path, "/"),
498                    $crate::endpoint::BASE_URL
499                ))?;
500                self.options.add_params(&mut url);
501                if let Some(status) = self.status {
502                    url = url.join(status.as_str())?;
503                }
504                Ok(::reqwest::Request::new(::reqwest::Method::GET, url))
505            }
506
507            fn from_response(
508                response: ::reqwest::Response,
509            ) -> impl ::std::future::Future<
510                Output = ::std::result::Result<Self::Response, $crate::endpoint::EndpointError>,
511            > + Send {
512                $crate::endpoint::ListResponse::from_response(response)
513            }
514        }
515
516        impl $crate::endpoint::PaginatedEndpoint for $name {
517            type Item = $response;
518
519            fn with_options(self, options: $crate::endpoint::CollectionOptions) -> Self {
520                Self { options, ..self }
521            }
522        }
523    };
524}
525pub(crate) use multi_list_endpoint;
526
527#[cfg(test)]
528mod tests {
529    use url::Url;
530
531    use super::*;
532
533    #[test]
534    fn test_collection_options_add_params() {
535        let mut url = Url::parse("https://example.com").unwrap();
536
537        let options = CollectionOptions::new()
538            .filter("foo", "bar")
539            .filter("foo", "baz")
540            .filter("qux", "quux")
541            .search("qux", "quux")
542            .range("corge", 1, 5)
543            .sort("grault")
544            .sort("-garply")
545            .page(3)
546            .per_page(4);
547        options.clone().add_params(&mut url);
548
549        assert!(url.query().is_some());
550
551        let options2 = CollectionOptions::from_url(url.as_str()).unwrap();
552        assert_eq!(options, options2);
553    }
554}