itchio-api 0.3.0

Easily interact with the itch.io server-side API
Documentation
use std::time::Duration;

use reqwest::{Client, StatusCode, header::{self, HeaderValue}};
use thiserror::Error;
use serde::{Deserialize, de::DeserializeOwned};
use url::Url;

mod parsers;
pub mod credentials_info;
pub mod download_keys;
pub mod me;
pub mod my_games;
pub mod purchases;
pub mod search;
pub mod users;
pub mod wharf;

/// Some details about a User on itch.io.
#[derive(Clone, Debug, Deserialize)]
pub struct SmallUser {
  pub id: u32,
  pub username: String,
  pub display_name: Option<String>,
  pub url: Url,
  pub cover_url: Option<Url>,
}

/// Many details about a User on itch.io.
#[derive(Clone, Debug, Deserialize)]
pub struct User {
  pub username: String,
  pub display_name: Option<String>,
  pub url: Url,
  pub cover_url: Option<Url>,
  pub press_user: bool,
  pub developer: bool,
  pub gamer: bool,
}

/// Details about how a Game can be played right from the store page.
#[derive(Clone, Debug, Deserialize)]
pub struct Embed {
  pub width: u32,
  pub height: u32,
  pub fullscreen: bool,
}

/// What you need to make requests, it stores and uses your API key.
pub struct Itchio {
  client: Client,
}

impl Itchio {
  /// Create a new Itchio client using your API key, which you can find there: <https://itch.io/user/settings/api-keys>
  ///
  /// Do note that an `Ok` `Result` doesn't mean the key is valid, try making a request (like with the `get_credentials_info` method)
  /// to check key validity
  pub fn new(key: String) -> Result<Self, ItchioError> {
    let mut headers = header::HeaderMap::new();
    let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", key))
      .map_err(|err| ItchioError::ClientCreationError(err.to_string()))?;
    auth_value.set_sensitive(true);
    headers.insert("Authorization", auth_value);

    let client = Client::builder()
      .timeout(Duration::from_secs(30))
      .default_headers(headers)
      .user_agent("itchio-api (https://codeberg.org/Taevas/itchio-api)")
      .build()
      .map_err(|err| ItchioError::ClientCreationError(err.to_string()))?;

    Ok(Self {
      client,
    })
  }

  /// Make your own requests by specifying an endpoint and struct to deserialize!
  pub async fn request<T: DeserializeOwned>(&self, endpoint: String) -> Result<T, ItchioError> {
    let url = format!("https://itch.io/api/1/key/{}", endpoint);
    let response = self
      .client
      .get(&url)
      .send()
      .await
      .map_err(|err| {
        match err.status() {
          Some(status) => match status {
            StatusCode::NOT_FOUND => ItchioError::BadEndpoint,
            StatusCode::TOO_MANY_REQUESTS => ItchioError::RateLimited,
            _ => ItchioError::RequestFailed(err),
          }
          None => ItchioError::RequestFailed(err),
        }
      })?;

    // We cannot consume the response twice, so instead of trying to call .json() twice,
    // let's use serde_json twice to consume references to a String containing what we want from the Response!
    let text = response
      .text()
      .await?;

    if let Ok(bad_response) = serde_json::from_str::<ErrorResponse>(&text) {
      if let Some(err_zero) = bad_response.errors.get(0) {
        let error = match err_zero.as_str() {
          "invalid key" => ItchioError::BadKey,
          "missing authorization header" => ItchioError::BadKey,
          "invalid api endpoint" => ItchioError::BadEndpoint, // should have already been handled by 404 status
          "invalid user" => ItchioError::NotFound,
          "invalid game" => ItchioError::NotFound,
          "invalid game_id" => ItchioError::NotFound, // applies also to game_ids that exist but unauthorized
          "no download key found" => ItchioError::NotFound,
          other => ItchioError::InternalError(other.to_string()),
        };
        return Err(error);
      } else {
        let message = bad_response.details.unwrap_or("Somehow received an empty error".to_string());
        return Err(ItchioError::InternalError(message))
      }
    }

    Ok(serde_json::from_str::<T>(&text)?)
  }
}

#[derive(Deserialize)]
struct ErrorResponse {
  errors: Vec<String>,
  details: Option<String>,
}

/// When something goes wrong, an ItchioError is used to describe what happened.
#[derive(Error, Debug)]
pub enum ItchioError {
  /// An error that happens when a reqwest client cannot be created.
  #[error("Couldn't create a client: {0}")]
  ClientCreationError(String),
  /// A generic error used when none of the other possible errors are fitting.
  #[error("HTTP request failed: {0}")]
  RequestFailed(#[from] reqwest::Error),
  /// Happens if the API gives us an unexpected object, likely means there's a mistake with this crate (or a bad struct given to request()).
  #[error("Failed to parse JSON: {0}")]
  JsonParseError(#[from] serde_json::Error),
  /// Due to certain circumstances, no other error can be used.
  #[error("An unexpected error happened: {0}")]
  InternalError(String),
  /// The key that was used was rejected by the server.
  #[error("Authentication failed.")]
  BadKey,
  /// The crate received a 404 status, meaning the endpoint doesn't exist or that there is a mistake with the URL.
  #[error("The requested endpoint doesn't exist.")]
  BadEndpoint,
  /// The crate received a 200 status with a specific error indicating the request is valid but the resource doesn't exist.
  #[error("The requested data doesn't exist.")]
  NotFound,
  /// The crate received a 429 status, meaning we sent too many requests in a given amount of time.
  #[error("The server is rate limiting us.")]
  RateLimited,
}