release 1.0
This commit is contained in:
@@ -1,136 +1,56 @@
|
||||
use image::load_from_memory;
|
||||
//! # 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;
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::model::alliance::Alliance;
|
||||
use crate::model::character::Character;
|
||||
use crate::model::corporation::Corporation;
|
||||
use crate::model::ship::Ship;
|
||||
use crate::cache::{SimpleCache, image::ImageCache};
|
||||
use crate::model::{Alliance, Character, Corporation, Ship};
|
||||
|
||||
impl EsiClient {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.user_agent("killpaper-hw,oli1111@web.de")
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
/// Structure to parse Alliance info response from json
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct AllianceInfo {
|
||||
name: String,
|
||||
ticker: String,
|
||||
}
|
||||
|
||||
Self {
|
||||
reqwest_client: client,
|
||||
}
|
||||
}
|
||||
/// Structure to parse Corporation info response from json
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct CorporationInfo {
|
||||
name: String,
|
||||
ticker: String,
|
||||
}
|
||||
|
||||
pub fn get_alliance(&self, id: u32) -> Result<Alliance, EsiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct AllianceInfo {
|
||||
name: String,
|
||||
ticker: String,
|
||||
}
|
||||
/// Structure to parse Character info response from json
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct CharacterInfo {
|
||||
name: String,
|
||||
}
|
||||
|
||||
let info_url = format!("https://esi.evetech.net/latest/alliances/{id}/");
|
||||
println!("REQ ESI{}", info_url);
|
||||
let info: AllianceInfo = self.reqwest_client.get(&info_url).send()?.json()?;
|
||||
/// Structure to parse Ship info response from json
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct ShipInfo {
|
||||
name: String,
|
||||
}
|
||||
|
||||
let logo_url = format!("https://images.evetech.net/alliances/{id}/logo?size=64");
|
||||
println!("REQ ESI{}", logo_url);
|
||||
|
||||
let bytes = self.reqwest_client.get(&logo_url).send()?.bytes()?;
|
||||
let img = load_from_memory(&bytes)?.to_rgb8();
|
||||
|
||||
Ok(Alliance {
|
||||
alliance_id: id,
|
||||
name: info.name,
|
||||
short: info.ticker,
|
||||
logo: img,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_corporation(&self, id: u32) -> Result<Corporation, EsiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct CorporationInfo {
|
||||
name: String,
|
||||
ticker: String,
|
||||
}
|
||||
|
||||
let info_url = format!("https://esi.evetech.net/latest/corporations/{id}/");
|
||||
println!("REQ ESI{}", info_url);
|
||||
let info: CorporationInfo = self.reqwest_client.get(&info_url).send()?.json()?;
|
||||
|
||||
let logo_url = format!("https://images.evetech.net/corporations/{id}/logo?size=64");
|
||||
println!("REQ ESI{}", logo_url);
|
||||
let bytes = self.reqwest_client.get(&logo_url).send()?.bytes()?;
|
||||
let img = load_from_memory(&bytes)?.to_rgb8();
|
||||
|
||||
Ok(Corporation {
|
||||
corporation_id: id,
|
||||
name: info.name,
|
||||
short: info.ticker,
|
||||
logo: img,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_character(&self, id: u32) -> Result<Character, EsiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct CharacterInfo {
|
||||
name: String,
|
||||
}
|
||||
|
||||
let info_url = format!("https://esi.evetech.net/latest/characters/{id}/");
|
||||
println!("REQ ESI{}", info_url);
|
||||
|
||||
let info: CharacterInfo = self.reqwest_client.get(&info_url).send()?.json()?;
|
||||
|
||||
let portrait_url = format!("https://images.evetech.net/characters/{id}/portrait?size=64");
|
||||
println!("REQ ESI{}", portrait_url);
|
||||
|
||||
let bytes = self.reqwest_client.get(&portrait_url).send()?.bytes()?;
|
||||
let img = load_from_memory(&bytes)?.to_rgb8();
|
||||
|
||||
Ok(Character {
|
||||
character_id: id,
|
||||
name: info.name,
|
||||
portrait: img,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_ship(&self, id: u32) -> Result<Ship, EsiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct ShipInfo {
|
||||
name: String,
|
||||
}
|
||||
|
||||
let info_url = format!("https://esi.evetech.net/latest/universe/types/{id}/");
|
||||
println!("REQ ESI{}", info_url);
|
||||
|
||||
let info: ShipInfo = self.reqwest_client.get(&info_url).send()?.json()?;
|
||||
|
||||
let icon_url = format!("https://images.evetech.net/types/{id}/icon?size=64");
|
||||
println!("REQ ESI{}", icon_url);
|
||||
|
||||
let bytes = self.reqwest_client.get(&icon_url).send()?.bytes()?;
|
||||
let img = load_from_memory(&bytes)?.to_rgb8();
|
||||
|
||||
Ok(Ship {
|
||||
ship_id: id,
|
||||
name: info.name,
|
||||
icon: img,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_system(&self, id: u32) -> Result<String, EsiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct SystemInfo {
|
||||
name: String,
|
||||
}
|
||||
|
||||
let url = format!("https://esi.evetech.net/latest/universe/systems/{id}/");
|
||||
println!("REQ ESI{}", url);
|
||||
let info: SystemInfo = self.reqwest_client.get(&url).send()?.json()?;
|
||||
|
||||
Ok(info.name)
|
||||
}
|
||||
/// Structure to parse System info response from json
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct SystemInfo {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -168,8 +88,293 @@ impl From<serde_json::Error> for EsiError {
|
||||
}
|
||||
|
||||
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)]
|
||||
@@ -178,7 +383,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_alliance() -> Result<(), EsiError> {
|
||||
let esi = EsiClient::new();
|
||||
let mut esi = EsiClient::new();
|
||||
let alliance = esi.get_alliance(99002685)?;
|
||||
assert_eq!(alliance.alliance_id, 99002685);
|
||||
|
||||
@@ -194,7 +399,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_corporation() -> Result<(), EsiError> {
|
||||
let esi = EsiClient::new();
|
||||
let mut esi = EsiClient::new();
|
||||
let corp = esi.get_corporation(98330748)?;
|
||||
assert_eq!(corp.corporation_id, 98330748);
|
||||
|
||||
@@ -210,7 +415,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_character() -> Result<(), EsiError> {
|
||||
let esi = EsiClient::new();
|
||||
let mut esi = EsiClient::new();
|
||||
let character = esi.get_character(90951867)?;
|
||||
|
||||
assert_eq!(character.character_id, 90951867);
|
||||
@@ -230,7 +435,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_ship() -> Result<(), EsiError> {
|
||||
let esi = EsiClient::new();
|
||||
let mut esi = EsiClient::new();
|
||||
let ship = esi.get_ship(587)?; // Rifter
|
||||
|
||||
assert_eq!(ship.ship_id, 587);
|
||||
@@ -245,7 +450,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_system() -> Result<(), EsiError> {
|
||||
let esi = EsiClient::new();
|
||||
let mut esi = EsiClient::new();
|
||||
let system_name = esi.get_system(30000142)?; // Jita
|
||||
|
||||
assert_eq!(system_name, "Jita");
|
||||
|
||||
@@ -1,2 +1,36 @@
|
||||
pub mod esi_static;
|
||||
pub mod zkill;
|
||||
//! # EVE Online API Clients
|
||||
//!
|
||||
//! This module provides clients for working with EVE Online data sources:
|
||||
//!
|
||||
//! - [`EsiClient`] → Cached access to EVE Swagger Interface (ESI) for alliances, corporations, characters, ships, and systems.
|
||||
//! - [`ZkillClient`] → Fetches kills, losses, and killmail details from [zKillboard](https://zkillboard.com) and ESI.
|
||||
//! - [`RedisQClient`] → Consumes real-time killmail notifications from [zKillboard's RedisQ stream](https://redisq.zkillboard.com).
|
||||
//!
|
||||
//! ## Example
|
||||
//! ```no_run
|
||||
//! use mycrate::api::{EsiClient, ZkillClient, RedisQClient};
|
||||
//!
|
||||
//! // Create an ESI client
|
||||
//! let mut esi = EsiClient::new();
|
||||
//! let corp = esi.get_corporation(123456789).unwrap();
|
||||
//! println!("Corporation: {} [{}]", corp.name, corp.short);
|
||||
//!
|
||||
//! // Create a Zkillboard client
|
||||
//! let zkill = ZkillClient::new();
|
||||
//! let kills = zkill.get_corporation_kills(123456789, 3600).unwrap();
|
||||
//!
|
||||
//! // Create a RedisQ client
|
||||
//! let mut redisq = RedisQClient::new("mychannel".into());
|
||||
//! redisq.set_corporation_id(123456789);
|
||||
//! if let Some((req, target)) = redisq.next() {
|
||||
//! println!("Received killmail {} as {:?}", req.id, target);
|
||||
//! }
|
||||
//! ```
|
||||
mod esi_static;
|
||||
mod redisq;
|
||||
mod zkill;
|
||||
|
||||
pub use esi_static::{EsiClient, EsiError};
|
||||
pub use redisq::RedisQClient;
|
||||
pub use redisq::Target;
|
||||
pub use zkill::{ZkillClient, ZkillError};
|
||||
|
||||
199
src/services/redisq.rs
Normal file
199
src/services/redisq.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! # RedisQ Client
|
||||
//!
|
||||
//! Provides [`RedisQClient`], a client for consuming real-time killmail
|
||||
//! notifications from zKillboard's [RedisQ](https://redisq.zkillboard.com) API.
|
||||
//!
|
||||
//! Features:
|
||||
//! - Subscribe to a unique queue/channel for killmail delivery.
|
||||
//! - Optional filtering by corporation ID (classify as Kill or Loss).
|
||||
//! - Blocking long-polling interface for stream consumption.
|
||||
//!
|
||||
//! Useful for live dashboards, bots, or services that react to new kills
|
||||
//! as they happen in EVE Online.
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::model::killmail::*;
|
||||
|
||||
/// A simplified killmail structure returned by RedisQ.
|
||||
///
|
||||
/// This is a subset of the official killmail format provided by zKillboard.
|
||||
/// It contains the essential information needed for filtering and processing.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct QKillmail {
|
||||
#[serde(rename = "solar_system_id")]
|
||||
pub _system_id: u32,
|
||||
#[serde(rename = "killmail_id")]
|
||||
pub killmail_id: u32,
|
||||
pub victim: QKillmailVictim,
|
||||
pub attackers: Vec<QKillmailAttacker>,
|
||||
}
|
||||
|
||||
/// Victim details inside a killmail.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct QKillmailVictim {
|
||||
pub corporation_id: u32,
|
||||
}
|
||||
|
||||
/// Attacker details inside a killmail.
|
||||
///
|
||||
/// Some attackers may not have a corporation ID (e.g., NPCs).
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct QKillmailAttacker {
|
||||
pub corporation_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// The root JSON wrapper returned by the RedisQ API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QKillmailPackage {
|
||||
package: QKillmailWrapper,
|
||||
}
|
||||
|
||||
/// Additional metadata about a kill from zKillboard.
|
||||
///
|
||||
/// Contains the hash (used to fetch the full killmail) and ISK value.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QZkbInfo {
|
||||
hash: String,
|
||||
#[serde(rename = "totalValue")]
|
||||
total_value: f64,
|
||||
}
|
||||
|
||||
/// Internal wrapper that groups killmail data with zKillboard info.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QKillmailWrapper {
|
||||
killmail: QKillmail,
|
||||
zkb: QZkbInfo,
|
||||
}
|
||||
|
||||
pub struct RedisQClient {
|
||||
client: Client,
|
||||
filter_corp_id: Option<u32>,
|
||||
channel: String,
|
||||
}
|
||||
|
||||
/// Represents whether a retrieved killmail is a kill or a loss
|
||||
/// relative to the configured corporation filter.
|
||||
pub enum Target {
|
||||
/// A kill where the configured corporation was one of the attackers.
|
||||
Kill,
|
||||
/// A loss where the configured corporation was the victim.
|
||||
Loss,
|
||||
}
|
||||
|
||||
/// Client for consuming killmails from [zKillboard's RedisQ stream](https://redisq.zkillboard.com/listen.php).
|
||||
///
|
||||
/// `RedisQClient` polls the zKillboard RedisQ endpoint and retrieves killmail
|
||||
/// notifications in near real-time. You can optionally filter by corporation ID
|
||||
/// to separate kills from losses.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// let mut client = RedisQClient::new("example_channel".into());
|
||||
/// client.set_corporation_id(123456789);
|
||||
///
|
||||
/// while let Some((km, target)) = client.next() {
|
||||
/// match target {
|
||||
/// Target::Kill => println!("We scored a kill worth {} ISK!", km.value),
|
||||
/// Target::Loss => println!("We suffered a loss: {}", km.id),
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
impl RedisQClient {
|
||||
/// Creates a new [`RedisQClient`] with the given channel name.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `channel` - A RedisQ queue identifier. Each client should have its own unique channel.
|
||||
pub fn new(channel: String) -> Self {
|
||||
let client = Client::builder()
|
||||
.user_agent("killpaper-hw,oli1111@web.de")
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
RedisQClient {
|
||||
client,
|
||||
filter_corp_id: None,
|
||||
channel,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the corporation ID to filter killmails against.
|
||||
///
|
||||
/// When set:
|
||||
/// - If the corporation appears among the attackers, the killmail is categorized as [`Target::Kill`].
|
||||
/// - If the corporation is the victim, it is categorized as [`Target::Loss`].
|
||||
/// - If neither, the killmail is ignored.
|
||||
///
|
||||
/// When Not set
|
||||
/// - All killmails are reported ass kills
|
||||
pub fn set_corporation_id(&mut self, id: u32) {
|
||||
self.filter_corp_id = Some(id)
|
||||
}
|
||||
|
||||
/// Retrieves the next killmail from the RedisQ stream.
|
||||
///
|
||||
/// This method performs a blocking HTTP request to the RedisQ endpoint,
|
||||
/// deserializes the response, applies the corporation filter (if set),
|
||||
/// and returns a tuple of:
|
||||
/// - [`KillmailRequest`] (containing ID, hash, and ISK value)
|
||||
/// - [`Target`] (Kill or Loss)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some((KillmailRequest, Target))` if a valid killmail was retrieved and passed the filter.
|
||||
/// * `None` if no valid killmail is available or the filter excluded it.
|
||||
///
|
||||
/// # Notes
|
||||
/// - RedisQ is a long-polling API; this call may block until a killmail is available.
|
||||
/// - Malformed or unexpected responses are logged to stdout and skipped.
|
||||
pub fn next(&self) -> Option<(KillmailRequest, Target)> {
|
||||
let url = format!(
|
||||
"https://zkillredisq.stream/listen.php?queueID={}",
|
||||
self.channel
|
||||
);
|
||||
|
||||
if let Ok(resp) = self.client.get(&url).send() {
|
||||
// Read the response as a string first
|
||||
if let Ok(text) = resp.text() {
|
||||
// Deserialize from the same string
|
||||
match serde_json::from_str::<QKillmailPackage>(&text) {
|
||||
Ok(mail) => {
|
||||
let km = mail.package.killmail.clone();
|
||||
|
||||
let target = if let Some(filter_id) = self.filter_corp_id {
|
||||
if km
|
||||
.attackers
|
||||
.iter()
|
||||
.any(|a| a.corporation_id == Some(filter_id))
|
||||
{
|
||||
Target::Kill
|
||||
} else if km.victim.corporation_id == filter_id {
|
||||
Target::Loss
|
||||
} else {
|
||||
// Corp_id filter set but no match
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
Target::Kill
|
||||
};
|
||||
|
||||
let request = KillmailRequest {
|
||||
id: km.killmail_id,
|
||||
hash: mail.package.zkb.hash,
|
||||
value: mail.package.zkb.total_value,
|
||||
};
|
||||
|
||||
return Some((request, target));
|
||||
}
|
||||
Err(err) => {
|
||||
println!("REDISQ_Error: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,46 @@
|
||||
//! # zKillboard Client
|
||||
//!
|
||||
//! Provides [`ZkillClient`], a client for querying [zKillboard](https://zkillboard.com)
|
||||
//! and retrieving killmails, kills, and losses.
|
||||
//!
|
||||
//! Features:
|
||||
//! - Fetch corporation kills or losses within a configurable time window.
|
||||
//! - Retrieve full killmail details (attackers, victim, system) via ESI.
|
||||
//! - Unified error handling through [`ZkillError`].
|
||||
//!
|
||||
//! Useful for historical data queries, killmail processing pipelines,
|
||||
//! or tools that need aggregated kill/loss information.
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::fmt;
|
||||
|
||||
use crate::model::killmail::*;
|
||||
|
||||
/// API structure to parse zKillboard's kill response JSON.
|
||||
///
|
||||
/// Returned when querying kills or losses from the zKillboard API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ZkbKill {
|
||||
/// The killmail ID.
|
||||
killmail_id: u32,
|
||||
/// Metadata about the kill from zKillboard.
|
||||
zkb: ZkbInfo,
|
||||
}
|
||||
|
||||
/// Metadata returned by zKillboard for a killmail.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ZkbInfo {
|
||||
/// Hash required to retrieve the full killmail via ESI.
|
||||
hash: String,
|
||||
totalValue: f64,
|
||||
/// Total ISK value of the kill.
|
||||
#[serde(rename = "totalValue")]
|
||||
total_value: f64,
|
||||
}
|
||||
|
||||
pub struct ZkillClient {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
// ESI API deserialization structs
|
||||
/// API structure representing an attacker inside an ESI killmail.
|
||||
///
|
||||
/// Values are optional since some attackers may lack certain fields (e.g., NPCs).
|
||||
#[derive(Deserialize)]
|
||||
struct EsiAttacker {
|
||||
character_id: Option<u32>,
|
||||
@@ -31,6 +51,9 @@ struct EsiAttacker {
|
||||
final_blow: Option<bool>,
|
||||
}
|
||||
|
||||
/// API structure representing the victim inside an ESI killmail.
|
||||
///
|
||||
/// Values are optional since not all fields are guaranteed to be present.
|
||||
#[derive(Deserialize)]
|
||||
struct EsiVictim {
|
||||
character_id: Option<u32>,
|
||||
@@ -39,18 +62,28 @@ struct EsiVictim {
|
||||
ship_type_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// API structure representing the core ESI killmail response.
|
||||
///
|
||||
/// Used to retrieve detailed killmail information from ESI
|
||||
/// after resolving it via zKillboard.
|
||||
#[derive(Deserialize)]
|
||||
struct EsiKillmail {
|
||||
/// The solar system where the kill occurred.
|
||||
solar_system_id: u32,
|
||||
/// List of attackers.
|
||||
attackers: Vec<EsiAttacker>,
|
||||
/// The victim.
|
||||
victim: EsiVictim,
|
||||
}
|
||||
|
||||
// Custom error type
|
||||
/// Error type for zKillboard and ESI API operations.
|
||||
#[derive(Debug)]
|
||||
pub enum ZkillError {
|
||||
/// Wrapper for `reqwest` errors during HTTP requests.
|
||||
Reqwest(reqwest::Error),
|
||||
/// Wrapper for `serde_json` errors during deserialization.
|
||||
Json(serde_json::Error),
|
||||
/// Returned when an entity (e.g., killmail) was not found.
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
@@ -78,7 +111,30 @@ impl From<serde_json::Error> for ZkillError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A client for interacting with [zKillboard](https://zkillboard.com) and
|
||||
/// fetching killmail information from both zKillboard and EVE ESI.
|
||||
///
|
||||
/// `ZkillClient` can:
|
||||
/// - Retrieve corporation kills or losses within a given time window.
|
||||
/// - Fetch full killmail details (attackers, victim, system) via ESI.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// let client = ZkillClient::new();
|
||||
/// let kills = client.get_corporation_kills(123456789, 3600).unwrap();
|
||||
/// for km in kills {
|
||||
/// let details = client.fetch_killmail_details(&km).unwrap();
|
||||
/// println!("Kill in system {} worth {} ISK", details.system_id, km.value);
|
||||
/// }
|
||||
/// ```
|
||||
pub struct ZkillClient {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ZkillClient {
|
||||
/// Creates a new [`ZkillClient`] with a configured `reqwest::Client`.
|
||||
///
|
||||
/// The client has compression enabled and a custom user agent set.
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.user_agent("killpaper-hw,oli1111@web.de")
|
||||
@@ -90,6 +146,17 @@ impl ZkillClient {
|
||||
ZkillClient { client }
|
||||
}
|
||||
|
||||
/// Fetches kills for a corporation within a given time window.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `corp_id` - The corporation ID to fetch kills for.
|
||||
/// * `past_seconds` - Look-back window in seconds.
|
||||
///
|
||||
/// # Returns
|
||||
/// A vector of [`KillmailRequest`] containing killmail IDs, hashes, and values.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`ZkillError`] if the request or JSON parsing fails.
|
||||
pub fn get_corporation_kills(
|
||||
&self,
|
||||
corp_id: u32,
|
||||
@@ -108,7 +175,7 @@ impl ZkillClient {
|
||||
.map(|k| KillmailRequest {
|
||||
id: k.killmail_id,
|
||||
hash: k.zkb.hash,
|
||||
value: k.zkb.totalValue,
|
||||
value: k.zkb.total_value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -133,7 +200,7 @@ impl ZkillClient {
|
||||
.map(|k| KillmailRequest {
|
||||
id: k.killmail_id,
|
||||
hash: k.zkb.hash,
|
||||
value: k.zkb.totalValue,
|
||||
value: k.zkb.total_value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user