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(¶ms),
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}