Skip to main content

itchio_api/
lib.rs

1use chrono::{DateTime, Utc};
2use reqwest::{Client, StatusCode};
3use thiserror::Error;
4use serde::{Deserialize, de::DeserializeOwned};
5use url::Url;
6
7mod parsers;
8use parsers::{date_from_str, option_date_from_str};
9
10/// What you need to make requests, it stores and uses your API key.
11pub struct Itchio {
12  client: Client,
13  key: String,
14}
15
16/// A raw response this crate gets from the server.
17#[derive(Clone, Debug, Deserialize)]
18struct Games {
19  games: Vec<Game>
20}
21
22/// A representation of a Game on the itch.io website.
23#[derive(Clone, Debug, Deserialize)] #[allow(dead_code)]
24pub struct Game {
25  pub id: u32,
26  pub title: String,
27  pub short_text: Option<String>,
28  pub url: Url,
29  pub cover_url: Option<Url>,
30  pub r#type: String,
31  pub classification: String,
32  pub p_linux: bool,
33  pub p_android: bool,
34  pub p_windows: bool,
35  pub p_osx: bool,
36  #[serde(deserialize_with = "date_from_str")]
37  pub created_at: DateTime<Utc>,
38  pub min_price: u32,
39  pub can_be_bought: bool,
40  pub published: bool,
41  // Note to self: When using a custom deserializer function, if the property is an Option because it might be missing in the JSON,
42  // "default" needs to be added to cover the missing field case
43  #[serde(default, deserialize_with = "option_date_from_str")]
44  pub published_at: Option<DateTime<Utc>>,
45  pub has_demo: bool,
46  pub embed: Option<Embed>,
47  pub user: User,
48  pub views_count: u64,
49  pub purchases_count: u64,
50  pub downloads_count: u64,
51  pub in_press_system: bool,
52  pub earnings: Option<Vec<Earning>>,
53}
54
55#[derive(Clone, Debug, Deserialize)] #[allow(dead_code)]
56pub struct User {
57  pub id: u32,
58  pub display_name: String,
59  pub username: String,
60  pub url: String,
61  pub cover_url: String,
62}
63
64#[derive(Clone, Debug, Deserialize)] #[allow(dead_code)]
65pub struct Embed {
66  pub width: u32,
67  pub height: u32,
68  pub fullscreen: bool,
69}
70
71/// How much money a Game has earned its developers.
72#[derive(Clone, Debug, Deserialize)] #[allow(dead_code)]
73pub struct Earning {
74  currency: String,
75  amount_formatted: String,
76  amount: u64,
77}
78
79impl Itchio {
80  /// Create a new Itchio client using your API key, which you can find there: <https://itch.io/user/settings/api-keys>
81  pub fn new(key: String) -> Self {
82    Self {
83      client: Client::new(),
84      key,
85    }
86  }
87
88  /// Make your own requests by specifying an endpoint and struct to deserialize!
89  pub async fn request<T: DeserializeOwned>(&self, endpoint: String) -> Result<T, ItchioError> {
90    let url = format!("https://itch.io/api/1/{}/{}", self.key, endpoint);
91    Ok(self
92      .client
93      .get(&url)
94      .send()
95      .await
96      .map_err(|err| {
97        match err.status() {
98          Some(status) => match status {
99            StatusCode::NOT_FOUND => ItchioError::BadEndpoint,
100            StatusCode::TOO_MANY_REQUESTS => ItchioError::RateLimited,
101            _ => ItchioError::RequestFailed(err),
102          }
103          None => ItchioError::RequestFailed(err),
104        }
105      })?
106      .json::<T>()
107      .await?
108    )
109  }
110
111  /// Get the games you've uploaded or have edit access to: <https://itch.io/docs/api/serverside#reference/profilegames-httpsitchioapi1keymy-games>
112  pub async fn get_my_games(&self) -> Result<Vec<Game>, ItchioError> {
113    let response = self.request::<Games>("my-games".to_string()).await?;
114    Ok(response.games)
115  }
116}
117
118// TODO Remove RequestFailed, add several other Errors, created from map_err on a request.send
119#[derive(Error, Debug)]
120pub enum ItchioError {
121  /// A generic error about the request itself failing.
122  #[error("HTTP request failed: {0}")]
123  RequestFailed(#[from] reqwest::Error),
124  /// Happens if the API gives us an unexpected object, likely means there's a mistake with this crate.
125  #[error("Failed to parse JSON: {0}")]
126  JsonParseError(#[from] serde_json::Error),
127  /// The crate received a 404 status, meaning the endpoint doesn't exist or that there is a mistake with the URL.
128  #[error("The requested endpoint doesn't exist.")]
129  BadEndpoint,
130  /// The crate received a 429 status, meaning we sent too many requests in a given amount of time.
131  #[error("The server is rate limiting us.")]
132  RateLimited,
133}
134
135#[cfg(test)]
136mod tests {
137  use super::*;
138  use std::env;
139  use dotenv::dotenv;
140
141  #[tokio::test]
142  async fn get_my_games_ok() {
143    dotenv().ok();
144    let client_secret = env::var("KEY").expect("KEY has to be set");
145    let api = Itchio::new(client_secret);
146    let games = api.get_my_games().await.inspect_err(|err| eprintln!("Error spotted: {}", err));
147    assert!(games.is_ok())
148  }
149
150  #[tokio::test]
151  async fn get_my_games_bad_key() {
152    let api = Itchio::new("bad_key".to_string());
153    let games = api.get_my_games().await;
154    assert!(games.is_err_and(|err| matches!(err, ItchioError::RequestFailed(_))))
155  }
156}