splits_io_api/
lib.rs

1#![warn(
2    clippy::complexity,
3    clippy::correctness,
4    clippy::perf,
5    clippy::style,
6    clippy::missing_const_for_fn,
7    clippy::undocumented_unsafe_blocks,
8    missing_docs,
9    rust_2018_idioms
10)]
11
12//! splits-io-api is a library that provides bindings for the splits.io API for Rust.
13//!
14//! ```no_run
15//! # use splits_io_api::{Client, Runner};
16//! # use anyhow::Context;
17//! #
18//! # async fn query_api() -> anyhow::Result<()> {
19//! // Create a splits.io API client.
20//! let client = Client::new();
21//!
22//! // Search for a runner.
23//! let runner = Runner::search(&client, "cryze")
24//!     .await?
25//!     .into_iter()
26//!     .next()
27//!     .context("There is no runner with that name")?;
28//!
29//! assert_eq!(&*runner.name, "cryze92");
30//!
31//! // Get the PBs for the runner.
32//! let first_pb = runner.pbs(&client)
33//!     .await?
34//!     .into_iter()
35//!     .next()
36//!     .context("This runner doesn't have any PBs")?;
37//!
38//! // Get the game for the PB.
39//! let game = first_pb.game.context("There is no game for the PB")?;
40//!
41//! assert_eq!(&*game.name, "The Legend of Zelda: The Wind Waker");
42//!
43//! // Get the categories for the game.
44//! let categories = game.categories(&client).await?;
45//!
46//! // Get the runs for the Any% category.
47//! let runs = categories
48//!     .iter()
49//!     .find(|category| &*category.name == "Any%")
50//!     .context("Couldn't find category")?
51//!     .runs(&client)
52//!     .await?;
53//!
54//! assert!(!runs.is_empty());
55//! # Ok(())
56//! # }
57//! ```
58
59use std::fmt;
60
61use reqwest::{header::AUTHORIZATION, RequestBuilder, Response, StatusCode};
62
63pub mod category;
64// pub mod event;
65pub mod game;
66pub mod race;
67pub mod run;
68pub mod runner;
69mod schema;
70mod wrapper;
71pub use schema::*;
72
73pub use uuid;
74
75/// A client that can access the splits.io API. This includes an access token that is used for
76/// authentication to all API endpoints.
77pub struct Client {
78    client: reqwest::Client,
79    access_token: Option<String>,
80}
81
82impl Default for Client {
83    fn default() -> Self {
84        #[allow(unused_mut)]
85        let mut builder = reqwest::Client::builder();
86        #[cfg(not(target_family = "wasm"))]
87        {
88            builder = builder.http2_prior_knowledge();
89            #[cfg(feature = "rustls")]
90            {
91                builder = builder.use_rustls_tls();
92            }
93        }
94
95        Client {
96            client: builder.build().unwrap(),
97            access_token: None,
98        }
99    }
100}
101
102impl Client {
103    /// Creates a new client.
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Sets the client's access token, which can be used to authenticate to all API endpoints.
109    pub fn set_access_token(&mut self, access_token: &str) {
110        let buf = self.access_token.get_or_insert_with(String::new);
111        buf.clear();
112        buf.push_str("Bearer ");
113        buf.push_str(access_token);
114    }
115}
116
117#[derive(Debug)]
118/// An error when making an API request.
119pub enum Error {
120    /// An HTTP error outside of the API.
121    Status {
122        /// The HTTP status code of the error.
123        status: StatusCode,
124    },
125    /// An error thrown by the API.
126    Api {
127        /// The HTTP status code of the error.
128        status: StatusCode,
129        /// The error message.
130        message: Box<str>,
131    },
132    /// Failed downloading the response.
133    Download {
134        /// The reason why downloading the response failed.
135        source: reqwest::Error,
136    },
137    /// The resource can not be sufficiently identified for finding resources
138    /// attached to it.
139    UnidentifiableResource,
140}
141
142impl fmt::Display for Error {
143    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            Error::Status { status } => {
146                write!(
147                    fmt,
148                    "HTTP Status: {}",
149                    status.canonical_reason().unwrap_or_else(|| status.as_str()),
150                )
151            }
152            Error::Api { message, .. } => fmt::Display::fmt(message, fmt),
153            Error::Download { .. } => {
154                fmt::Display::fmt("Failed downloading the response.", fmt)
155            }
156            Error::UnidentifiableResource => {
157                fmt::Display::fmt(
158                    "The resource can not be sufficiently identified for finding resources attached to it.",
159                    fmt,
160                )
161            }
162        }
163    }
164}
165
166impl std::error::Error for Error {
167    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
168        match self {
169            Error::Status { .. } => None,
170            Error::Api { .. } => None,
171            Error::Download { source, .. } => Some(source),
172            Error::UnidentifiableResource => None,
173        }
174    }
175}
176
177async fn get_response_unchecked(
178    client: &Client,
179    mut request: RequestBuilder,
180) -> Result<Response, Error> {
181    // FIXME: Only for requests that need it.
182    if let Some(access_token) = &client.access_token {
183        request = request.header(AUTHORIZATION, access_token);
184    }
185
186    request
187        .send()
188        .await
189        .map_err(|source| Error::Download { source })
190}
191
192async fn get_response(client: &Client, request: RequestBuilder) -> Result<Response, Error> {
193    let response = get_response_unchecked(client, request).await?;
194    let status = response.status();
195    if !status.is_success() {
196        if let Ok(error) = response.json::<ApiError>().await {
197            return Err(Error::Api {
198                status,
199                message: error.error,
200            });
201        }
202        return Err(Error::Status { status });
203    }
204    Ok(response)
205}
206
207async fn get_json<T: serde::de::DeserializeOwned>(
208    client: &Client,
209    request: RequestBuilder,
210) -> Result<T, Error> {
211    let response = get_response(client, request).await?;
212    response
213        .json()
214        .await
215        .map_err(|source| Error::Download { source })
216}
217
218#[derive(serde_derive::Deserialize)]
219struct ApiError {
220    #[serde(alias = "message")]
221    error: Box<str>,
222}