//! # 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 for EsiError { fn from(e: reqwest::Error) -> Self { EsiError::Http(e) } } impl From for EsiError { fn from(e: image::ImageError) -> Self { EsiError::Image(e) } } impl From 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, corp_cache: SimpleCache, char_cache: SimpleCache, ship_cache: SimpleCache, system_cache: SimpleCache, 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( id: u32, cache: &mut SimpleCache, client: &Client, request: String, ) -> Result 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 { 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 { 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 { 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 { 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 { 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 { 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(()) } }