462 lines
14 KiB
Rust
462 lines
14 KiB
Rust
//! # ESI Static Client
|
|
//!
|
|
//! Provides [`EsiClient`], a cached client for retrieving data from the
|
|
//! [EVE Online ESI API](https://esi.evetech.net/).
|
|
//!
|
|
//! Features:
|
|
//! - Fetch alliance, corporation, character, ship, and system information.
|
|
//! - Transparent in-memory caching with [`SimpleCache`].
|
|
//! - On-disk image caching with [`ImageCache`] for logos, portraits, and icons.
|
|
//!
|
|
//! Intended for applications that repeatedly request static entity data
|
|
//! (like killboards, dashboards, or analysis tools).
|
|
|
|
use image::{RgbImage, load_from_memory};
|
|
use reqwest::blocking::Client;
|
|
use serde::{Deserialize, de::DeserializeOwned};
|
|
use std::fmt;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use crate::cache::{SimpleCache, image::ImageCache};
|
|
use crate::model::{Alliance, Character, Corporation, Ship};
|
|
|
|
/// Structure to parse Alliance info response from json
|
|
#[derive(Deserialize, Clone)]
|
|
struct AllianceInfo {
|
|
name: String,
|
|
ticker: String,
|
|
}
|
|
|
|
/// Structure to parse Corporation info response from json
|
|
#[derive(Deserialize, Clone)]
|
|
struct CorporationInfo {
|
|
name: String,
|
|
ticker: String,
|
|
}
|
|
|
|
/// Structure to parse Character info response from json
|
|
#[derive(Deserialize, Clone)]
|
|
struct CharacterInfo {
|
|
name: String,
|
|
}
|
|
|
|
/// Structure to parse Ship info response from json
|
|
#[derive(Deserialize, Clone)]
|
|
struct ShipInfo {
|
|
name: String,
|
|
}
|
|
|
|
/// Structure to parse System info response from json
|
|
#[derive(Deserialize, Clone)]
|
|
struct SystemInfo {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum EsiError {
|
|
Http(reqwest::Error),
|
|
Image(image::ImageError),
|
|
Json(serde_json::Error),
|
|
}
|
|
|
|
impl fmt::Display for EsiError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
EsiError::Http(e) => write!(f, "HTTP request error: {}", e),
|
|
EsiError::Image(e) => write!(f, "Image decoding error: {}", e),
|
|
EsiError::Json(e) => write!(f, "JSON parsing error: {}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Conversions so we can use `?`
|
|
impl From<reqwest::Error> for EsiError {
|
|
fn from(e: reqwest::Error) -> Self {
|
|
EsiError::Http(e)
|
|
}
|
|
}
|
|
impl From<image::ImageError> for EsiError {
|
|
fn from(e: image::ImageError) -> Self {
|
|
EsiError::Image(e)
|
|
}
|
|
}
|
|
impl From<serde_json::Error> for EsiError {
|
|
fn from(e: serde_json::Error) -> Self {
|
|
EsiError::Json(e)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for EsiError {}
|
|
|
|
/// A client for interacting with the [EVE Online ESI API](https://esi.evetech.net/).
|
|
///
|
|
/// `EsiClient` provides cached access to alliance, corporation, character,
|
|
/// ship, and system information, as well as associated images (logos, portraits, icons).
|
|
/// It uses both in-memory caches (`SimpleCache`) and on-disk image caches (`ImageCache`)
|
|
/// to minimize redundant API calls and improve performance.
|
|
///
|
|
/// # Example
|
|
/// ```no_run
|
|
/// let mut esi = EsiClient::new();
|
|
/// let alliance = esi.get_alliance(99000006).unwrap();
|
|
/// println!("Alliance: {} [{}]", alliance.name, alliance.short);
|
|
/// ```
|
|
pub struct EsiClient {
|
|
pub(crate) reqwest_client: Client,
|
|
alli_cache: SimpleCache<AllianceInfo>,
|
|
corp_cache: SimpleCache<CorporationInfo>,
|
|
char_cache: SimpleCache<CharacterInfo>,
|
|
ship_cache: SimpleCache<ShipInfo>,
|
|
system_cache: SimpleCache<SystemInfo>,
|
|
|
|
alli_image_cache: ImageCache,
|
|
corp_image_cache: ImageCache,
|
|
char_image_cache: ImageCache,
|
|
ship_image_cache: ImageCache,
|
|
}
|
|
|
|
impl EsiClient {
|
|
/// Creates a new `EsiClient` instance with default configuration.
|
|
///
|
|
/// Initializes the underlying `reqwest::Client` with compression enabled,
|
|
/// sets a custom user agent, and ensures cache directories for images exist.
|
|
///
|
|
/// # Panics
|
|
/// Panics if cache directories cannot be created.
|
|
pub fn new() -> Self {
|
|
let client = Client::builder()
|
|
.user_agent("killpaper-hw,oli1111@web.de")
|
|
.gzip(true)
|
|
.brotli(true)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let alli_img_path = "cache/alli";
|
|
let corp_img_path = "cache/corp";
|
|
let char_img_path = "cache/char";
|
|
let ship_img_path = "cache/ship";
|
|
|
|
for dir in [alli_img_path, corp_img_path, char_img_path, ship_img_path] {
|
|
fs::create_dir_all(Path::new(dir)).expect("could not create cache dir");
|
|
}
|
|
|
|
Self {
|
|
reqwest_client: client,
|
|
alli_cache: SimpleCache::new(),
|
|
corp_cache: SimpleCache::new(),
|
|
char_cache: SimpleCache::new(),
|
|
ship_cache: SimpleCache::new(),
|
|
system_cache: SimpleCache::new(),
|
|
|
|
alli_image_cache: ImageCache::new(alli_img_path.to_string()),
|
|
corp_image_cache: ImageCache::new(corp_img_path.to_string()),
|
|
char_image_cache: ImageCache::new(char_img_path.to_string()),
|
|
ship_image_cache: ImageCache::new(ship_img_path.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Fetches and caches a JSON resource from ESI.
|
|
///
|
|
/// Checks the provided [`SimpleCache`] for the given `id`.
|
|
/// If not cached, it performs a GET request, deserializes the response,
|
|
/// stores it in the cache, and returns the result.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`EsiError`] if the request or deserialization fails.
|
|
fn request_with_cache<T>(
|
|
id: u32,
|
|
cache: &mut SimpleCache<T>,
|
|
client: &Client,
|
|
request: String,
|
|
) -> Result<T, EsiError>
|
|
where
|
|
T: Clone + DeserializeOwned,
|
|
{
|
|
if let Some(value) = cache.lookup(id) {
|
|
//println!("VALUE hit {}", id);
|
|
Ok(value)
|
|
} else {
|
|
//println!("VALUE miss {} {}",id, request);
|
|
let info: T = client.get(&request).send()?.json()?;
|
|
cache.insert(id, &info);
|
|
Ok(info)
|
|
}
|
|
}
|
|
|
|
/// Fetches and caches an image resource from ESI.
|
|
///
|
|
/// Checks the provided [`ImageCache`] for the given `id`.
|
|
/// If not cached, it performs a GET request, decodes the image into `RgbImage`,
|
|
/// stores it in the cache, and returns the result.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`EsiError`] if the request, decoding, or caching fails.
|
|
fn request_image_with_cache(
|
|
id: u32,
|
|
cache: &mut ImageCache,
|
|
client: &Client,
|
|
request: String,
|
|
) -> Result<RgbImage, EsiError> {
|
|
if let Some(value) = cache.lookup(id) {
|
|
//println!("IMG hit {}", id);
|
|
Ok(value)
|
|
} else {
|
|
//println!("IMG miss{} {}",id, request);
|
|
let bytes = client.get(&request).send()?.bytes()?;
|
|
let img = load_from_memory(&bytes)?.to_rgb8();
|
|
cache.insert(id, &img)?;
|
|
Ok(img)
|
|
}
|
|
}
|
|
|
|
/// Retrieves information about an alliance, including its name, ticker, and logo.
|
|
///
|
|
/// Uses ESI's alliance information and image endpoints.
|
|
///
|
|
/// # Arguments
|
|
/// * `id` - The alliance ID.
|
|
///
|
|
/// # Returns
|
|
/// An [`Alliance`] object containing the alliance details.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`EsiError`] if the request or image retrieval fails.
|
|
pub fn get_alliance(&mut self, id: u32) -> Result<Alliance, EsiError> {
|
|
let info_url = format!("https://esi.evetech.net/latest/alliances/{id}/");
|
|
let info = EsiClient::request_with_cache(
|
|
id,
|
|
&mut self.alli_cache,
|
|
&self.reqwest_client,
|
|
info_url,
|
|
)?;
|
|
|
|
let logo_url = format!("https://images.evetech.net/alliances/{id}/logo?size=64");
|
|
let img = EsiClient::request_image_with_cache(
|
|
id,
|
|
&mut self.alli_image_cache,
|
|
&self.reqwest_client,
|
|
logo_url,
|
|
)?;
|
|
|
|
Ok(Alliance {
|
|
alliance_id: id,
|
|
name: info.name,
|
|
short: info.ticker,
|
|
logo: img,
|
|
})
|
|
}
|
|
|
|
/// Retrieves information about a corporation, including its name, ticker, and logo.
|
|
///
|
|
/// Uses ESI's corporation information and image endpoints.
|
|
///
|
|
/// # Arguments
|
|
/// * `id` - The corporation ID.
|
|
///
|
|
/// # Returns
|
|
/// A [`Corporation`] object containing the corporation details.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`EsiError`] if the request or image retrieval fails.
|
|
pub fn get_corporation(&mut self, id: u32) -> Result<Corporation, EsiError> {
|
|
let info_url = format!("https://esi.evetech.net/latest/corporations/{id}/");
|
|
let info = EsiClient::request_with_cache(
|
|
id,
|
|
&mut self.corp_cache,
|
|
&self.reqwest_client,
|
|
info_url,
|
|
)?;
|
|
|
|
let logo_url = format!("https://images.evetech.net/corporations/{id}/logo?size=64");
|
|
let img = EsiClient::request_image_with_cache(
|
|
id,
|
|
&mut self.corp_image_cache,
|
|
&self.reqwest_client,
|
|
logo_url,
|
|
)?;
|
|
|
|
Ok(Corporation {
|
|
corporation_id: id,
|
|
name: info.name,
|
|
short: info.ticker,
|
|
logo: img,
|
|
})
|
|
}
|
|
|
|
/// Retrieves information about a character, including its name and portrait.
|
|
///
|
|
/// Uses ESI's character information and image endpoints.
|
|
///
|
|
/// # Arguments
|
|
/// * `id` - The character ID.
|
|
///
|
|
/// # Returns
|
|
/// A [`Character`] object containing the character details.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`EsiError`] if the request or image retrieval fails.
|
|
pub fn get_character(&mut self, id: u32) -> Result<Character, EsiError> {
|
|
let info_url = format!("https://esi.evetech.net/latest/characters/{id}/");
|
|
let info = EsiClient::request_with_cache(
|
|
id,
|
|
&mut self.char_cache,
|
|
&self.reqwest_client,
|
|
info_url,
|
|
)?;
|
|
|
|
let portrait_url = format!("https://images.evetech.net/characters/{id}/portrait?size=64");
|
|
let img = EsiClient::request_image_with_cache(
|
|
id,
|
|
&mut self.char_image_cache,
|
|
&self.reqwest_client,
|
|
portrait_url,
|
|
)?;
|
|
|
|
Ok(Character {
|
|
character_id: id,
|
|
name: info.name,
|
|
portrait: img,
|
|
})
|
|
}
|
|
|
|
/// Retrieves information about a ship type, including its name and icon.
|
|
///
|
|
/// Uses ESI's universe type information and image endpoints.
|
|
///
|
|
/// # Arguments
|
|
/// * `id` - The ship type ID.
|
|
///
|
|
/// # Returns
|
|
/// A [`Ship`] object containing the ship details.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`EsiError`] if the request or image retrieval fails.
|
|
pub fn get_ship(&mut self, id: u32) -> Result<Ship, EsiError> {
|
|
let info_url = format!("https://esi.evetech.net/latest/universe/types/{id}/");
|
|
let info = EsiClient::request_with_cache(
|
|
id,
|
|
&mut self.ship_cache,
|
|
&self.reqwest_client,
|
|
info_url,
|
|
)?;
|
|
|
|
let icon_url = format!("https://images.evetech.net/types/{id}/icon?size=64");
|
|
let img = EsiClient::request_image_with_cache(
|
|
id,
|
|
&mut self.ship_image_cache,
|
|
&self.reqwest_client,
|
|
icon_url,
|
|
)?;
|
|
|
|
Ok(Ship {
|
|
ship_id: id,
|
|
name: info.name,
|
|
icon: img,
|
|
})
|
|
}
|
|
|
|
/// Retrieves the name of a solar system.
|
|
///
|
|
/// Uses ESI's universe system endpoint.
|
|
///
|
|
/// # Arguments
|
|
/// * `id` - The system ID.
|
|
///
|
|
/// # Returns
|
|
/// The system name as a `String`.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`EsiError`] if the request fails.
|
|
pub fn get_system(&mut self, id: u32) -> Result<String, EsiError> {
|
|
let url = format!("https://esi.evetech.net/latest/universe/systems/{id}/");
|
|
let info =
|
|
EsiClient::request_with_cache(id, &mut self.system_cache, &self.reqwest_client, url)?;
|
|
|
|
Ok(info.name)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_get_alliance() -> Result<(), EsiError> {
|
|
let mut esi = EsiClient::new();
|
|
let alliance = esi.get_alliance(99002685)?;
|
|
assert_eq!(alliance.alliance_id, 99002685);
|
|
|
|
println!("Alliance name: {:?}", alliance.name);
|
|
println!("Alliance ticker: {:?}", alliance.short);
|
|
println!("image {} {}", alliance.logo.width(), alliance.logo.height());
|
|
|
|
assert!(alliance.logo.width() > 0);
|
|
assert!(alliance.logo.height() > 0);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_corporation() -> Result<(), EsiError> {
|
|
let mut esi = EsiClient::new();
|
|
let corp = esi.get_corporation(98330748)?;
|
|
assert_eq!(corp.corporation_id, 98330748);
|
|
|
|
println!("Corporation name: {:?}", corp.name);
|
|
println!("Corporation ticker: {:?}", corp.short);
|
|
println!("image {} {}", corp.logo.width(), corp.logo.height());
|
|
|
|
assert!(corp.logo.width() > 0);
|
|
assert!(corp.logo.height() > 0);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_character() -> Result<(), EsiError> {
|
|
let mut esi = EsiClient::new();
|
|
let character = esi.get_character(90951867)?;
|
|
|
|
assert_eq!(character.character_id, 90951867);
|
|
|
|
println!("Character name: {:?}", character.name);
|
|
println!(
|
|
"image {} {}",
|
|
character.portrait.width(),
|
|
character.portrait.height()
|
|
);
|
|
|
|
assert!(character.portrait.width() > 0);
|
|
assert!(character.portrait.height() > 0);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_ship() -> Result<(), EsiError> {
|
|
let mut esi = EsiClient::new();
|
|
let ship = esi.get_ship(587)?; // Rifter
|
|
|
|
assert_eq!(ship.ship_id, 587);
|
|
println!("Ship: {}", ship.name);
|
|
println!("image {} {}", ship.icon.width(), ship.icon.height());
|
|
|
|
assert!(ship.icon.width() > 0);
|
|
assert!(ship.icon.height() > 0);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_system() -> Result<(), EsiError> {
|
|
let mut esi = EsiClient::new();
|
|
let system_name = esi.get_system(30000142)?; // Jita
|
|
|
|
assert_eq!(system_name, "Jita");
|
|
println!("System name: {}", system_name);
|
|
|
|
Ok(())
|
|
}
|
|
}
|