eversal-esi 0.2.0

ESI Library for the Eversal project
Documentation
mod alliance;
pub use alliance::*;
mod assets;
pub use assets::*;
mod auth;
pub use auth::*;
mod character;
pub use character::*;
mod corporation;
pub use corporation::*;
mod killmails;
pub use killmails::*;
mod location;
pub use location::*;
mod market;
pub use market::*;
mod scope;
pub use scope::Scope;
mod error;
pub use error::Error;
mod universe;
pub use universe::*;

pub type EsiResult<T> = Result<T, Error>;

use oauth2::{
  basic::{
    BasicClient,
    BasicErrorResponse,
    BasicRevocationErrorResponse,
    BasicTokenIntrospectionResponse,
    BasicTokenResponse,
  },
  AuthUrl,
  ClientId,
  ClientSecret,
  EndpointNotSet,
  EndpointSet,
  RedirectUrl,
  StandardRevocableToken,
  TokenUrl,
};
use reqwest::Client;
use serde::{de::DeserializeOwned, Serialize};
use std::{collections::HashMap, env, time::Duration};

const ESI_DATASOURCE: &str = "tranquility";
const AUTHORIZE_URL: &str = "https://login.eveonline.com/v2/oauth/authorize";
const TOKEN_URL: &str = "https://login.eveonline.com/v2/oauth/token";
const SSO_META_DATA_URL: &str =
  "https://login.eveonline.com/.well-known/oauth-authorization-server";
const LOGIN_URLS: [&str; 2] = ["login.eveonline.com", "https://login.eveonline.com"];
const LOGIN_MEMBERS: [&str; 1] = ["EVE Online"];

pub type ClientType = oauth2::Client<
  BasicErrorResponse,
  BasicTokenResponse,
  BasicTokenIntrospectionResponse,
  StandardRevocableToken,
  BasicRevocationErrorResponse,
  EndpointSet,
  EndpointNotSet,
  EndpointNotSet,
  EndpointNotSet,
  EndpointSet,
>;

pub struct Esi {
  client: Client,
  token_client: ClientType,
  base_url: String,
}

impl Esi {
  pub fn new(
    owner_id: impl Into<String>,
    client_id: impl Into<String>,
    client_secret: impl Into<String>,
    callback_url: impl Into<String>,
    timeout: u64,
  ) -> EsiResult<Self> {
    let version = env!("CARGO_PKG_VERSION");
    let user_agent = format!(
      "{}/{}; {} ({} {})",
      "eversal",
      version,
      owner_id.into(),
      "ben@bensherriff.com",
      "https://gitea.bensherriff.com/Eversal/eversal-esi"
    );
    let client = Client::builder()
      .user_agent(&user_agent)
      .timeout(Duration::from_secs(timeout))
      .build()?;

    let token_client = BasicClient::new(ClientId::new(client_id.into()))
      .set_client_secret(ClientSecret::new(client_secret.into()))
      .set_auth_uri(AuthUrl::new(AUTHORIZE_URL.to_string())?)
      .set_token_uri(TokenUrl::new(TOKEN_URL.to_string())?)
      .set_redirect_uri(RedirectUrl::new(callback_url.into())?);

    Ok(Self {
      client,
      token_client,
      base_url: "https://esi.evetech.net/latest/".to_string(),
    })
  }
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Headers {
  pub etag: String,
  pub expires: String,
  pub last_modified: String,
  pub error_limit_remain: u16,
  pub error_limit_reset: u16,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Paged<T> {
  pub data: T,
  pub page: i32,
  pub total_pages: i32,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Response<T> {
  pub data: T,
  pub headers: Headers,
}

async fn process_single<T: DeserializeOwned>(
  response: reqwest::Response,
) -> EsiResult<Response<T>> {
  let headers = headers(&response);
  let data: T = response.json().await?;
  Ok(Response { data, headers })
}

async fn process_paged<T: DeserializeOwned>(
  page: i32,
  response: reqwest::Response,
) -> EsiResult<Response<Paged<T>>> {
  let total_pages = total_pages(&response);
  let headers = headers(&response);
  let data: T = response.json().await?;
  Ok(Response {
    data: Paged {
      data,
      page,
      total_pages,
    },
    headers,
  })
}

async fn get_public_base(
  path: &str,
  esi: &Esi,
  params: Option<HashMap<&str, String>>,
  etag: Option<&str>,
) -> EsiResult<reqwest::Response> {
  let url = build_url(&esi.base_url, path, params);
  log::trace!("Requesting: {}", url);
  let mut request = esi.client.get(url);
  if let Some(etag) = etag {
    request = request.header(reqwest::header::IF_NONE_MATCH, etag);
  }
  let response: reqwest::Response = request.send().await?;

  response.error_for_status_ref()?;
  if response.status().as_u16() == 304 {
    return Err(Error::new(304, "Not Modified".to_string()));
  }
  Ok(response)
}

pub async fn get_public<T: DeserializeOwned>(
  path: &str,
  esi: &Esi,
  params: Option<HashMap<&str, String>>,
  etag: Option<&str>,
) -> EsiResult<Response<T>> {
  let response = get_public_base(path, esi, params, etag).await?;
  process_single(response).await
}

pub async fn get_public_paged<T: DeserializeOwned>(
  path: &str,
  esi: &Esi,
  params: Option<HashMap<&str, String>>,
  etag: Option<&str>,
) -> EsiResult<Response<Paged<T>>> {
  let page = page(&params);
  let response = get_public_base(path, esi, params, etag).await?;
  process_paged(page, response).await
}

async fn get_authenticated_base(
  path: &str,
  esi: &Esi,
  access_token: &str,
  params: Option<HashMap<&str, String>>,
  etag: Option<&str>,
) -> EsiResult<reqwest::Response> {
  let url = build_url(&esi.base_url, path, params);
  log::trace!("Requesting: {}", url);

  let mut request = esi.client.get(url).header(
    reqwest::header::AUTHORIZATION,
    format!("Bearer {}", access_token),
  );
  if let Some(etag) = etag {
    request = request.header(reqwest::header::IF_NONE_MATCH, etag);
  }
  let response = request.send().await?;

  response.error_for_status_ref()?;
  if response.status().as_u16() == 304 {
    return Err(Error::new(304, "Not Modified".to_string()));
  }

  Ok(response)
}

pub async fn get_authenticated<T: DeserializeOwned>(
  access_token: &str,
  path: &str,
  esi: &Esi,
  params: Option<HashMap<&str, String>>,
  etag: Option<&str>,
) -> EsiResult<Response<T>> {
  let response = get_authenticated_base(path, esi, access_token, params, etag).await?;
  process_single(response).await
}

pub async fn get_authenticated_paged<T: DeserializeOwned>(
  access_token: &str,
  path: &str,
  esi: &Esi,
  params: Option<HashMap<&str, String>>,
  etag: Option<&str>,
) -> EsiResult<Response<Paged<T>>> {
  let page = page(&params);
  let response = get_authenticated_base(&path, esi, access_token, params, etag).await?;
  process_paged(page, response).await
}

pub async fn post_public<T: DeserializeOwned, U: Serialize + ?Sized>(
  path: &str,
  esi: &Esi,
  params: Option<HashMap<&str, String>>,
  data: &U,
) -> Result<T, reqwest::Error> {
  let url = build_url(&esi.base_url, path, params);
  log::trace!(
    "Requesting: {} with data: {}",
    url,
    serde_json::to_string(data).unwrap()
  );
  let response = esi.client.post(url).json(data).send().await?;

  response.error_for_status_ref()?;
  let result: T = response.json().await?;
  Ok(result)
}

pub async fn post_authenticated<T: DeserializeOwned, U: Serialize + ?Sized>(
  access_token: &str,
  path: &str,
  esi: &Esi,
  params: Option<HashMap<&str, String>>,
  data: &U,
) -> Result<T, reqwest::Error> {
  let url = build_url(&esi.base_url, path, params);
  log::trace!(
    "Requesting: {} with data: {}",
    url,
    serde_json::to_string(data).unwrap()
  );
  let req = esi
    .client
    .post(url)
    .header(
      reqwest::header::AUTHORIZATION,
      format!("Bearer {}", access_token),
    )
    .json(data)
    .send()
    .await?;

  req.error_for_status_ref()?;
  let result: T = req.json().await?;
  Ok(result)
}

fn build_url(base_url: &str, path: &str, params: Option<HashMap<&str, String>>) -> String {
  let mut url = format!("{}{}?datasource={}", base_url, path, ESI_DATASOURCE);
  if let Some(params) = params {
    for (key, value) in params {
      url.push_str(&format!("&{}={}", key, value));
    }
  }
  url
}

fn headers(response: &reqwest::Response) -> Headers {
  Headers {
    etag: response
      .headers()
      .get("ETag")
      .unwrap()
      .to_str()
      .unwrap()
      .to_string(),
    // Parse from RFC 1123
    expires: response
      .headers()
      .get("Expires")
      .unwrap()
      .to_str()
      .unwrap()
      .to_string(),
    last_modified: response
      .headers()
      .get("Last-Modified")
      .unwrap()
      .to_str()
      .unwrap()
      .to_string(),
    error_limit_remain: response
      .headers()
      .get("x-esi-error-limit-remain")
      .unwrap()
      .to_str()
      .unwrap()
      .to_string()
      .parse()
      .unwrap(),
    error_limit_reset: response
      .headers()
      .get("x-esi-error-limit-reset")
      .unwrap()
      .to_str()
      .unwrap()
      .to_string()
      .parse()
      .unwrap(),
  }
}

fn page(params: &Option<HashMap<&str, String>>) -> i32 {
  match &params {
    Some(params) => match params.get("page") {
      Some(page) => page.parse::<i32>().unwrap_or_else(|_| 1),
      None => 1,
    },
    None => 1,
  }
}

fn total_pages(response: &reqwest::Response) -> i32 {
  match response.headers().get("X-Pages") {
    Some(pages) => match pages.to_str() {
      Ok(pages) => pages.parse::<i32>().unwrap_or_else(|_| 1),
      Err(_) => 1,
    },
    None => 1,
  }
}