scratch_io/
itch_api.rs

1pub mod errors;
2mod responses;
3pub mod types;
4
5use errors::*;
6use responses::*;
7pub use responses::{ApiResponse, IntoResponseResult, LoginResponse};
8use types::*;
9
10use reqwest::{Method, Response, header};
11
12/// A client able to send requests to the itch.io API
13#[derive(Debug, Clone)]
14pub struct ItchClient {
15  client: reqwest::Client,
16  api_key: String,
17}
18
19/// This block defiles the [`ItchClient`] API calls
20impl ItchClient {
21  /// Make a request to the itch.io API
22  ///
23  /// # Arguments
24  ///
25  /// * `url` - An itch.io API address to make the request against
26  ///
27  /// * `method` - The request method (GET, POST, etc.)
28  ///
29  /// * `options` - A closure that modifies the request builder just before sending it
30  ///
31  /// # Returns
32  ///
33  /// The reqwest [`Response`]
34  ///
35  /// # Errors
36  ///
37  /// If the request fails to send
38  pub(crate) async fn itch_request(
39    &self,
40    url: &ItchApiUrl,
41    method: Method,
42    options: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
43  ) -> Result<Response, reqwest::Error> {
44    // Create the base request
45    let mut request: reqwest::RequestBuilder = self.client.request(method, url.as_str());
46
47    // Add authentication based on the API's version.
48    request = match url.get_version() {
49      // https://itchapi.ryhn.link/API/V1/index.html#authentication
50      ItchApiVersion::V1 => {
51        request.header(header::AUTHORIZATION, format!("Bearer {}", &self.api_key))
52      }
53      // https://itchapi.ryhn.link/API/V2/index.html#authentication
54      ItchApiVersion::V2 => request.header(header::AUTHORIZATION, &self.api_key),
55      // If it isn't a known API version, just leave it without authentication
56      // Giving any authentication to an untrusted site is insecure because the API key could be stolen
57      ItchApiVersion::Other => request,
58    };
59
60    // This header is set to ensure the use of the v2 version
61    // https://itchapi.ryhn.link/API/V2/index.html
62    if let ItchApiVersion::V2 = url.get_version() {
63      request = request.header(header::ACCEPT, "application/vnd.itch.v2");
64    }
65
66    // The callback is the final option before sending because
67    // it needs to be able to modify anything
68    request = options(request);
69
70    request.send().await
71  }
72
73  /// Make a request to the itch.io API and parse the response as JSON
74  ///
75  /// # Arguments
76  ///
77  /// * `url` - An itch.io API address to make the request against
78  ///
79  /// * `method` - The request method (GET, POST, etc.)
80  ///
81  /// * `options` - A closure that modifies the request builder just before sending it
82  ///
83  /// # Returns
84  ///
85  /// The JSON response parsed into the provided type
86  ///
87  /// # Errors
88  ///
89  /// If the request, retrieving its text, or parsing fails, or if the server returned an error
90  async fn itch_request_json<T>(
91    &self,
92    url: &ItchApiUrl,
93    method: Method,
94    options: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
95  ) -> Result<T, ItchRequestJSONError<<T as IntoResponseResult>::Err>>
96  where
97    T: serde::de::DeserializeOwned + IntoResponseResult,
98  {
99    // Get the response text
100    let text = self
101      .itch_request(url, method, options)
102      .await
103      .map_err(|e| ItchRequestJSONError {
104        url: url.to_string(),
105        kind: ItchRequestJSONErrorKind::CouldntSend(e),
106      })?
107      .text()
108      .await
109      .map_err(|e| ItchRequestJSONError {
110        url: url.to_string(),
111        kind: ItchRequestJSONErrorKind::CouldntGetText(e),
112      })?;
113
114    // Parse the response into JSON
115    serde_json::from_str::<ApiResponse<T>>(&text)
116      .map_err(|error| ItchRequestJSONError {
117        url: url.to_string(),
118        kind: ItchRequestJSONErrorKind::InvalidJSON { body: text, error },
119      })?
120      .into_result()
121      .map_err(|e| ItchRequestJSONError {
122        url: url.to_string(),
123        kind: ItchRequestJSONErrorKind::ServerRepliedWithError(e),
124      })
125  }
126}
127
128/// This block defines the [`ItchClient`] constructors and other functions
129impl ItchClient {
130  /// Obtain the API key associated with this [`ItchClient`]
131  #[must_use]
132  pub fn get_api_key(&self) -> &str {
133    &self.api_key
134  }
135
136  /// Create a new client using the provided itch.io API key, without verifying its validity
137  ///
138  /// # Arguments
139  ///
140  /// * `api_key` - A valid itch.io API key to store in the client
141  ///
142  /// # Returns
143  ///
144  /// An [`ItchClient`] struct with the given key
145  #[must_use]
146  pub fn new(api_key: String) -> Self {
147    Self {
148      client: reqwest::Client::new(),
149      api_key,
150    }
151  }
152
153  /// Create a new client using the provided itch.io API key and verify its validity
154  ///
155  /// # Arguments
156  ///
157  /// * `api_key` - A valid itch.io API key to store in the client
158  ///
159  /// # Returns
160  ///
161  /// An [`ItchClient`] struct with the given key
162  ///
163  /// # Errors
164  ///
165  /// If the request, retrieving its text, or parsing fails, or if the server returned an error
166  pub async fn auth(
167    api_key: String,
168  ) -> Result<Self, ItchRequestJSONError<ApiResponseCommonErrors>> {
169    let client = ItchClient::new(api_key);
170
171    // Verify that the API key is valid
172    // Calling get_profile will fail if the given API key is invalid
173    get_profile(&client).await?;
174
175    Ok(client)
176  }
177}
178
179/// Login to itch.io
180///
181/// Retrieve a API key from a username and password authentication
182///
183/// # Arguments
184///
185/// * `username` - The username OR email of the accout to log in with
186///
187/// * `password` - The password of the accout to log in with
188///
189/// * `recaptcha_response` - If required, the reCAPTCHA token from <https://itch.io/captcha>
190///
191/// * `totp_code` - If required, The 6-digit code returned by the TOTP application
192///
193/// # Returns
194///
195/// A [`LoginResponse`] enum with the response from the API, which can be either the API key or an error
196///
197/// # Errors
198///
199/// If the requests fail
200pub async fn login(
201  client: &ItchClient,
202  username: &str,
203  password: &str,
204  recaptcha_response: Option<&str>,
205) -> Result<LoginResponse, ItchRequestJSONError<LoginResponseError>> {
206  let mut params: Vec<(&'static str, &str)> = vec![
207    ("username", username),
208    ("password", password),
209    ("force_recaptcha", "false"),
210    // source can be any of types::ItchKeySource
211    ("source", "desktop"),
212  ];
213
214  if let Some(rr) = recaptcha_response {
215    params.push(("recaptcha_response", rr));
216  }
217
218  client
219    .itch_request_json::<LoginResponse>(
220      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, "login"),
221      Method::POST,
222      |b| b.form(&params),
223    )
224    .await
225}
226
227/// Complete the login with the TOTP two-factor verification
228///
229/// # Arguments
230///
231/// * `totp_token` - The TOTP token returned by the previous login step
232///
233/// * `totp_code` - The 6-digit code returned by the TOTP application
234///
235/// # Returns
236///
237/// A [`LoginSuccess`] struct with the new API key
238///
239/// # Errors
240///
241/// If something goes wrong
242pub async fn totp_verification(
243  client: &ItchClient,
244  totp_token: &str,
245  totp_code: u64,
246) -> Result<LoginSuccess, ItchRequestJSONError<TOTPResponseError>> {
247  client
248    .itch_request_json::<TOTPResponse>(
249      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, "totp/verify"),
250      Method::POST,
251      |b| b.form(&[("token", totp_token), ("code", &totp_code.to_string())]),
252    )
253    .await
254    .map(|res| res.success)
255}
256
257/// Get a user's info
258///
259/// # Arguments
260///
261/// * `client` - An itch.io API client
262///
263/// # Returns
264///
265/// A [`User`] struct with the info provided by the API
266///
267/// # Errors
268///
269/// If the request, retrieving its text, or parsing fails, or if the server returned an error
270pub async fn get_user_info(
271  client: &ItchClient,
272  user_id: UserID,
273) -> Result<User, ItchRequestJSONError<UserResponseError>> {
274  client
275    .itch_request_json::<UserInfoResponse>(
276      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("users/{user_id}")),
277      Method::GET,
278      |b| b,
279    )
280    .await
281    .map(|res| res.user)
282}
283
284/// Get the API key's profile
285///
286/// This can be used to verify that a given itch.io API key is valid
287///
288/// # Arguments
289///
290/// * `client` - An itch.io API client
291///
292/// # Returns
293///
294/// A [`Profile`] struct with the info provided by the API
295///
296/// # Errors
297///
298/// If the request, retrieving its text, or parsing fails, or if the server returned an error
299pub async fn get_profile(
300  client: &ItchClient,
301) -> Result<Profile, ItchRequestJSONError<ApiResponseCommonErrors>> {
302  client
303    .itch_request_json::<ProfileInfoResponse>(
304      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, "profile"),
305      Method::GET,
306      |b| b,
307    )
308    .await
309    .map(|res| res.user)
310}
311
312/// Get the games that the user created or that the user is an admin of
313///
314/// # Arguments
315///
316/// * `client` - An itch.io API client
317///
318/// # Returns
319///
320/// A vector of [`CreatedGame`] structs with the info provided by the API
321///
322/// # Errors
323///
324/// If the request, retrieving its text, or parsing fails, or if the server returned an error
325pub async fn get_created_games(
326  client: &ItchClient,
327) -> Result<Vec<CreatedGame>, ItchRequestJSONError<ApiResponseCommonErrors>> {
328  client
329    .itch_request_json::<CreatedGamesResponse>(
330      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, "profile/games"),
331      Method::GET,
332      |b| b,
333    )
334    .await
335    .map(|res| res.games)
336}
337
338/// Get the user's owned game keys
339///
340/// # Arguments
341///
342/// * `client` - An itch.io API client
343///
344/// # Returns
345///
346/// A vector of [`OwnedKey`] structs with the info provided by the API
347///
348/// # Errors
349///
350/// If the request, retrieving its text, or parsing fails, or if the server returned an error
351pub async fn get_owned_keys(
352  client: &ItchClient,
353) -> Result<Vec<OwnedKey>, ItchRequestJSONError<ApiResponseCommonErrors>> {
354  let mut values: Vec<OwnedKey> = Vec::new();
355  let mut page: u64 = 1;
356  loop {
357    let response = client
358      .itch_request_json::<OwnedKeysResponse>(
359        &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, "profile/owned-keys"),
360        Method::GET,
361        |b| b.query(&[("page", page)]),
362      )
363      .await?;
364
365    let response_values = response.owned_keys;
366    let num_elements: u64 = response_values.len() as u64;
367    values.extend(response_values.into_iter());
368
369    if num_elements == 0 || num_elements < response.per_page {
370      break;
371    }
372
373    page += 1;
374  }
375
376  Ok(values)
377}
378
379/// List the user's game collections
380///
381/// # Arguments
382///
383/// * `client` - An itch.io API client
384///
385/// # Returns
386///
387/// A vector of [`Collection`] structs with the info provided by the API
388///
389/// # Errors
390///
391/// If the request, retrieving its text, or parsing fails, or if the server returned an error
392pub async fn get_profile_collections(
393  client: &ItchClient,
394) -> Result<Vec<Collection>, ItchRequestJSONError<ApiResponseCommonErrors>> {
395  client
396    .itch_request_json::<ProfileCollectionsResponse>(
397      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, "profile/collections"),
398      Method::GET,
399      |b| b,
400    )
401    .await
402    .map(|res| res.collections)
403}
404
405/// Get a collection's info
406///
407/// # Arguments
408///
409/// * `client` - An itch.io API client
410///
411/// * `collection_id` - The ID of the collection from which information will be obtained
412///
413/// # Returns
414///
415/// A [`Collection`] struct with the info provided by the API
416///
417/// # Errors
418///
419/// If the request, retrieving its text, or parsing fails, or if the server returned an error
420pub async fn get_collection_info(
421  client: &ItchClient,
422  collection_id: CollectionID,
423) -> Result<Collection, ItchRequestJSONError<CollectionResponseError>> {
424  client
425    .itch_request_json::<CollectionInfoResponse>(
426      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("collections/{collection_id}")),
427      Method::GET,
428      |b| b,
429    )
430    .await
431    .map(|res| res.collection)
432}
433
434/// List a collection's games
435///
436/// # Arguments
437///
438/// * `client` - An itch.io API client
439///
440/// * `collection_id` - The ID of the collection from which information will be obtained
441///
442/// # Returns
443///
444/// A vector of [`CollectionGameItem`] structs with the info provided by the API
445///
446/// # Errors
447///
448/// If the request, retrieving its text, or parsing fails, or if the server returned an error
449pub async fn get_collection_games(
450  client: &ItchClient,
451  collection_id: CollectionID,
452) -> Result<Vec<CollectionGameItem>, ItchRequestJSONError<CollectionResponseError>> {
453  let mut values: Vec<CollectionGameItem> = Vec::new();
454  let mut page: u64 = 1;
455  loop {
456    let response = client
457      .itch_request_json::<CollectionGamesResponse>(
458        &ItchApiUrl::from_api_endpoint(
459          ItchApiVersion::V2,
460          format!("collections/{collection_id}/collection-games"),
461        ),
462        Method::GET,
463        |b| b.query(&[("page", page)]),
464      )
465      .await?;
466
467    let response_values = response.collection_games;
468    let num_elements: u64 = response_values.len() as u64;
469    values.extend(response_values.into_iter());
470
471    if num_elements == 0 || num_elements < response.per_page {
472      break;
473    }
474
475    page += 1;
476  }
477
478  Ok(values)
479}
480
481/// Get the information about a game in itch.io
482///
483/// # Arguments
484///
485/// * `client` - An itch.io API client
486///
487/// * `game_id` - The ID of the game from which information will be obtained
488///
489/// # Returns
490///
491/// A [`Game`] struct with the info provided by the API
492///
493/// # Errors
494///
495/// If the request, retrieving its text, or parsing fails, or if the server returned an error
496pub async fn get_game_info(
497  client: &ItchClient,
498  game_id: GameID,
499) -> Result<Game, ItchRequestJSONError<GameResponseError>> {
500  client
501    .itch_request_json::<GameInfoResponse>(
502      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("games/{game_id}")),
503      Method::GET,
504      |b| b,
505    )
506    .await
507    .map(|res| res.game)
508}
509
510/// Get the game's uploads (downloadable files)
511///
512/// # Arguments
513///
514/// * `client` - An itch.io API client
515///
516/// * `game_id` - The ID of the game from which information will be obtained
517///
518/// # Returns
519///
520/// A vector of [`Upload`] structs with the info provided by the API
521///
522/// # Errors
523///
524/// If the request, retrieving its text, or parsing fails, or if the server returned an error
525pub async fn get_game_uploads(
526  client: &ItchClient,
527  game_id: GameID,
528) -> Result<Vec<Upload>, ItchRequestJSONError<GameResponseError>> {
529  client
530    .itch_request_json::<GameUploadsResponse>(
531      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("games/{game_id}/uploads")),
532      Method::GET,
533      |b| b,
534    )
535    .await
536    .map(|res| res.uploads)
537}
538
539/// Get an upload's info
540///
541/// # Arguments
542///
543/// * `client` - An itch.io API client
544///
545/// * `upload_id` - The ID of the upload from which information will be obtained
546///
547/// # Returns
548///
549/// An [`Upload`] struct with the info provided by the API
550///
551/// # Errors
552///
553/// If the request, retrieving its text, or parsing fails, or if the server returned an error
554pub async fn get_upload_info(
555  client: &ItchClient,
556  upload_id: UploadID,
557) -> Result<Upload, ItchRequestJSONError<UploadResponseError>> {
558  client
559    .itch_request_json::<UploadInfoResponse>(
560      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("uploads/{upload_id}")),
561      Method::GET,
562      |b| b,
563    )
564    .await
565    .map(|res| res.upload)
566}
567
568/// Get the upload's builds (downloadable versions)
569///
570/// # Arguments
571///
572/// * `client` - An itch.io API client
573///
574/// * `upload_id` - The ID of the upload from which information will be obtained
575///
576/// # Returns
577///
578/// A vector of [`UploadBuild`] structs with the info provided by the API
579///
580/// # Errors
581///
582/// If the request, retrieving its text, or parsing fails, or if the server returned an error
583pub async fn get_upload_builds(
584  client: &ItchClient,
585  upload_id: UploadID,
586) -> Result<Vec<UploadBuild>, ItchRequestJSONError<UploadResponseError>> {
587  client
588    .itch_request_json::<UploadBuildsResponse>(
589      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("uploads/{upload_id}/builds")),
590      Method::GET,
591      |b| b,
592    )
593    .await
594    .map(|res| res.builds)
595}
596
597/// Get a build's info
598///
599/// # Arguments
600///
601/// * `client` - An itch.io API client
602///
603/// * `build_id` - The ID of the build from which information will be obtained
604///
605/// # Returns
606///
607/// A [`Build`] struct with the info provided by the API
608///
609/// # Errors
610///
611/// If the request, retrieving its text, or parsing fails, or if the server returned an error
612pub async fn get_build_info(
613  client: &ItchClient,
614  build_id: BuildID,
615) -> Result<Build, ItchRequestJSONError<BuildResponseError>> {
616  client
617    .itch_request_json::<BuildInfoResponse>(
618      &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("builds/{build_id}")),
619      Method::GET,
620      |b| b,
621    )
622    .await
623    .map(|res| res.build)
624}
625
626/// Get the upgrade path between two upload builds
627///
628/// # Arguments
629///
630/// * `client` - An itch.io API client
631///
632/// * `current_build_id` - The ID of the current build
633///
634/// * `target_build_id` - The ID of the target build
635///
636/// # Returns
637///
638/// A vector of [`UpgradePathBuild`] structs with the info provided by the API
639///
640/// # Errors
641///
642/// If the request, retrieving its text, or parsing fails, or if the server returned an error
643pub async fn get_upgrade_path(
644  client: &ItchClient,
645  current_build_id: BuildID,
646  target_build_id: BuildID,
647) -> Result<Vec<UpgradePathBuild>, ItchRequestJSONError<UpgradePathResponseError>> {
648  client
649    .itch_request_json::<BuildUpgradePathResponse>(
650      &ItchApiUrl::from_api_endpoint(
651        ItchApiVersion::V2,
652        format!("builds/{current_build_id}/upgrade-paths/{target_build_id}"),
653      ),
654      Method::GET,
655      |b| b,
656    )
657    .await
658    .map(|res| res.upgrade_path.builds)
659}
660
661/// Get additional information about the contents of the upload
662///
663/// # Arguments
664///
665/// * `client` - An itch.io API client
666///
667/// * `upload_id` - The ID of the upload from which information will be obtained
668///
669/// # Returns
670///
671/// A [`ScannedArchive`] struct with the info provided by the API
672///
673/// # Errors
674///
675/// If the request, retrieving its text, or parsing fails, or if the server returned an error
676pub async fn get_upload_scanned_archive(
677  client: &ItchClient,
678  upload_id: UploadID,
679) -> Result<ScannedArchive, ItchRequestJSONError<UploadResponseError>> {
680  client
681    .itch_request_json::<UploadScannedArchiveResponse>(
682      &ItchApiUrl::from_api_endpoint(
683        ItchApiVersion::V2,
684        format!("uploads/{upload_id}/scanned-archive"),
685      ),
686      Method::GET,
687      |b| b,
688    )
689    .await
690    .map(|res| res.scanned_archive)
691}
692
693/// Get additional information about the contents of the build
694///
695/// # Arguments
696///
697/// * `client` - An itch.io API client
698///
699/// * `build_id` - The ID of the build from which information will be obtained
700///
701/// # Returns
702///
703/// A [`ScannedArchive`] struct with the info provided by the API
704///
705/// # Errors
706///
707/// If the request, retrieving its text, or parsing fails, or if the server returned an error
708pub async fn get_build_scanned_archive(
709  client: &ItchClient,
710  build_id: BuildID,
711) -> Result<ScannedArchive, ItchRequestJSONError<BuildResponseError>> {
712  client
713    .itch_request_json::<BuildScannedArchiveResponse>(
714      &ItchApiUrl::from_api_endpoint(
715        ItchApiVersion::V2,
716        format!("builds/{build_id}/scanned-archive"),
717      ),
718      Method::GET,
719      |b| b,
720    )
721    .await
722    .map(|res| res.scanned_archive)
723}