//! This module is responsible for accessing the Guild Wars 2 API. //! //! It contains `Deserialize`able definitions for the required API responses, as well as a wrapper //! around the HTTP library. Note that only the required fields are modelled here, which means this //! does not provide a full-featured mapping for all API types. pub mod legends; pub mod professions; pub mod skills; pub mod specializations; pub mod traits; pub use self::{ legends::Legend, professions::Profession, skills::Skill, specializations::Specialization, traits::Trait, }; use image::DynamicImage; use itertools::Itertools; use reqwest::{blocking::Client, StatusCode, Url}; use serde::{de::DeserializeOwned, Serialize}; use std::path::Path; use thiserror::Error; use super::cache::{Cache, CacheError}; /// The base URL of the official Guild Wars 2 API. const BASE_URL: &str = "https://api.guildwars2.com/v2/"; #[derive(Error, Debug)] pub enum ApiError { #[error("The requested item could not be found in the API")] ItemNotFound, #[error("Error deserializing the API response")] SerializationError(#[from] serde_json::Error), #[error("Error accessing the cache")] CacheError(#[from] CacheError), #[error("Underlying HTTP error")] HttpError(#[from] reqwest::Error), #[error("Image loading error")] ImageError(#[from] image::ImageError), } trait ApiResponse where Self: Sized, { fn ensure_found(self) -> Result; } impl ApiResponse for reqwest::blocking::Response { fn ensure_found(self) -> Result { if self.status() == StatusCode::PARTIAL_CONTENT || self.status() == StatusCode::NOT_FOUND { Err(ApiError::ItemNotFound) } else { Ok(self) } } } /// Trait for API objects that have an ID. /// /// This is used by [`Api`](struct.Api.html) to properly retrieve and cache objects. trait HasId { type Id: ToString + Eq + Clone; fn get_id(&self) -> Self::Id; } /// The main API access struct. /// /// This takes care of caching given the provided cache implementation, as well as keeping a HTTP /// connection pool around to re-use. pub struct Api { cache: Box, base_url: Url, client: Client, } /// API access for the GW2 api. impl Api { /// Create a new API instance with the given cache underlying. pub fn new(cache: C) -> Api { Api { cache: Box::new(cache), base_url: Url::parse(BASE_URL).unwrap(), client: Client::new(), } } /// Combines the given endpoint with the `base_url` of this API. fn make_url(&self, endpoint: &str) -> Url { let mut result = self.base_url.join(endpoint).expect("Invalid API endpoint"); result.set_query(Some("v=2019-19-12T0:00")); result } /// Get and deserialize a cached value. fn get_cached(&mut self, name: P) -> Result, ApiError> where T: DeserializeOwned, P: AsRef, { match self.cache.get(name.as_ref())? { Some(data) => serde_json::from_slice(&data).or(Ok(None)), None => Ok(None), } } /// Serialize and store a value in the cache. fn save_cached(&mut self, name: P, value: &T) -> Result<(), ApiError> where T: Serialize, P: AsRef, { self.cache .store(name.as_ref(), &serde_json::to_vec(value)?)?; Ok(()) } /// Retrieves a list of elements by their ID. /// /// This function first checks the cache if elements can be found there, and otherwise hits the /// API. fn get_multiple_cached( &mut self, endpoint: &str, cache_prefix: &str, ids: &[R::Id], ) -> Result, ApiError> where R: HasId + DeserializeOwned + Serialize, { let mut result: Vec = Vec::new(); let mut api_ids: Vec = Vec::new(); for id in ids { let cache_path = format!("{}{}", cache_prefix, sanitize_id(&id.to_string())); match self.get_cached(cache_path)? { Some(cached) => result.push(cached), None => api_ids.push(id.clone()), } } if api_ids.is_empty() { return Ok(result); } let url = self.make_url(endpoint); let api_arg = api_ids .iter() .map(ToString::to_string) .map(|s| sanitize_id(&s)) .join(","); let resp: Vec = self .client .get(url) .query(&[("ids", api_arg)]) .send()? .ensure_found()? .json()?; for result in &resp { let cache_path = format!("{}{}", cache_prefix, result.get_id().to_string()); self.save_cached(cache_path, result)?; } result.extend(resp.into_iter()); Ok(result) } /// Retrieve a list of all professions using the professions endpoint. pub fn get_profession_ids(&mut self) -> Result, ApiError> { if let Some(cached) = self.get_cached("profession_ids")? { return Ok(cached); } let url = self.make_url("professions"); let resp = self.client.get(url).send()?.json()?; self.save_cached("profession_ids", &resp)?; Ok(resp) } /// Retrieve detailed information about the given professions. /// /// Professions that are found in the cache are taken from there. Therefore, the order of the /// return vector is not guaranteed to be the same as the input order. pub fn get_professions(&mut self, ids: &[String]) -> Result, ApiError> { self.get_multiple_cached("professions", "professions/", ids) } /// Retrieve all available professions. /// /// This is a shortcut around `get_profession_ids` and `get_professions`. pub fn get_all_professions(&mut self) -> Result, ApiError> { let ids = self.get_profession_ids()?; self.get_professions(&ids) } /// Retrieve detailed information about the given skills. /// /// Skills that are found in the cache are taken from there. pub fn get_skills(&mut self, ids: &[u32]) -> Result, ApiError> { self.get_multiple_cached("skills", "skills/", ids) } /// Retrieve detailed information about the given specializations. /// /// Specializations that are found in the cache are taken from there. pub fn get_specializations(&mut self, ids: &[u32]) -> Result, ApiError> { self.get_multiple_cached("specializations", "specializations/", ids) } /// Retrieve detailed information about the given traits. /// /// Traits that are found in the cache are taken from there. pub fn get_traits(&mut self, ids: &[u32]) -> Result, ApiError> { self.get_multiple_cached("traits", "traits/", ids) } /// Retrieve detailed information about the given legends. /// /// Traits that are found in the cache are taken from there. pub fn get_legends(&mut self, ids: &[String]) -> Result, ApiError> { // As of 2022-11-28, the API only knows about 6 legends, missing the latest ("Legendary // Alliance"). Therefore, we fake the API response here. let to_fake = super::bt::Legend::Alliance.api_id().unwrap(); let mut missing_ids = Vec::new(); let mut result = Vec::new(); for id in ids { if id == &to_fake { // Hardcoded values result.push(Legend { id: to_fake.clone(), swap: 62749, heal: 62719, elite: 62942, utilities: vec![62832, 62962, 62878], }); } else { missing_ids.push(id.clone()); } } result.extend(self.get_multiple_cached("legends", "legends/", &missing_ids)?); Ok(result) } /// Loads the image from the given URL. /// /// This automatically caches and also decodes the resulting data. pub fn get_image(&mut self, url: &str) -> Result { let hashed_url = format!("images/{:x}", md5::compute(url.as_bytes())); if let Some(data) = self.cache.get(Path::new(&hashed_url))? { return Ok(image::load_from_memory(&data)?); } let mut img = Vec::new(); self.client.get(url).send()?.copy_to(&mut img)?; self.cache.store(Path::new(&hashed_url), &img)?; Ok(image::load_from_memory(&img)?) } } fn sanitize_id(input: &str) -> String { input.replace( |c: char| { let is_valid = c.is_ascii() && (c.is_ascii_digit() || c.is_alphabetic()); !is_valid }, "", ) } #[cfg(test)] mod tests { use super::*; #[test] fn test_sanitize() { assert_eq!("foobar", sanitize_id("../foo/bar")); assert_eq!("foo1", sanitize_id("foo1")); } }