1use 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
36pub 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#[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#[derive(Debug, Clone, Eq, PartialEq, Default)]
73pub struct CollectionOptions {
74 filters: HashMap<CompactString, Vec<CompactString>>,
76 search: HashMap<CompactString, CompactString>,
78 range: HashMap<CompactString, (i64, i64)>,
80 sort: HashSet<CompactString>,
82
83 page: Option<u32>,
85 per_page: Option<u32>,
87}
88
89impl CollectionOptions {
90 #[must_use]
92 pub fn new() -> Self {
93 Self::default()
94 }
95
96 #[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 #[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 #[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 #[must_use]
142 pub fn sort(mut self, key: impl Into<CompactString>) -> Self {
143 self.sort.insert(key.into());
144 self
145 }
146
147 #[must_use]
151 pub const fn page(mut self, page: u32) -> Self {
152 self.page = Some(page);
153 self
154 }
155
156 #[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", &page.to_compact_string());
193 }
194 if let Some(per_page) = self.per_page {
195 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 KEY_REGEX.get_or_init(|| Regex::new(r"([a-z_]+)(\[(.+)])?").unwrap())
265}
266
267#[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 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 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 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}