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
10pub struct Itchio {
12 client: Client,
13 key: String,
14}
15
16#[derive(Clone, Debug, Deserialize)]
18struct Games {
19 games: Vec<Game>
20}
21
22#[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 #[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#[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 pub fn new(key: String) -> Self {
82 Self {
83 client: Client::new(),
84 key,
85 }
86 }
87
88 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 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#[derive(Error, Debug)]
120pub enum ItchioError {
121 #[error("HTTP request failed: {0}")]
123 RequestFailed(#[from] reqwest::Error),
124 #[error("Failed to parse JSON: {0}")]
126 JsonParseError(#[from] serde_json::Error),
127 #[error("The requested endpoint doesn't exist.")]
129 BadEndpoint,
130 #[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}