diff --git a/Cargo.toml b/Cargo.toml index 97cfa5f..c4afcd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,11 @@ documentation = "https://docs.rs/warframe" homepage = "https://docs.rs/warframe" repository = "https://github.com/Mettwasser/warframe.rs" license = "MIT" -rust-version = "1.85" +rust-version = "1.91" +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] [features] default = ["market_ratelimit", "market_cache"] @@ -20,28 +23,28 @@ market_ratelimit = ["dep:governor"] market_cache = ["dep:moka"] [dependencies] -tokio = { version = "1.39.3", features = ["full"] } -reqwest = { version = "0.12.7", features = ["json"] } -chrono = { version = "0.4.38", features = ["serde", "clock"] } -serde = { version = "1.0.209", features = ["derive"] } -serde_json = { version = "1.0.127" } -serde_repr = "0.1.19" -futures = "0.3.30" -thiserror = "2.0.11" -moka = { version = "0.12.8", optional = true, features = ["future"] } +tokio = { version = "1.48.0", features = ["full"] } +reqwest = { version = "0.12.28", features = ["json"] } +chrono = { version = "0.4.42", features = ["serde", "clock"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.148" } +serde_repr = "0.1.20" +futures = "0.3.31" +thiserror = "2.0.17" +moka = { version = "0.12.12", optional = true, features = ["future"] } urlencoding = "2.1.3" -derive_more = { version = "2.0.1", features = ["full"] } -serde_with = { version = "3.11.0" } +derive_more = { version = "2.1.1", features = ["full"] } +serde_with = { version = "3.16.1" } warframe-macros = { path = "warframe-macros", version = "8.0.1" } paste = "1.0.15" -tracing = "0.1.41" -governor = { version = "0.10.0", optional = true } +tracing = "0.1.44" +governor = { version = "0.10.4", optional = true } derive_builder = "0.20.2" heck = "0.5.0" [dev-dependencies] rstest = "0.25.0" -tracing-subscriber = "0.3.19" +tracing-subscriber = "0.3.22" [lints.clippy] pedantic = "warn" diff --git a/README.md b/README.md index 24bf9ce..32dd89a 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ An async crate to wrap the [Worldstate API](https://docs.warframestat.us) and th Use this crate if you want to make a Warframe-related rust project that is async. ## Getting started + To install, simply run `cargo add warframe`. -Note that the MSRV of this project is `1.85`. +Note that the MSRV of this project is `1.91`. ### Example + ```rust,no_run use warframe::worldstate::{Client, Error, queryable::Cetus, Opposite, TimedEvent}; @@ -30,6 +32,7 @@ async fn main() -> Result<(), Error> { ``` ## Contributing + See [CONTRIBUTING](CONTRIBUTING.md) ### Commitlint diff --git a/src/lib.rs b/src/lib.rs index 9f3918a..c71df36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] #![doc = include_str!("../README.md")] pub mod worldstate; diff --git a/src/worldstate/client.rs b/src/worldstate/client.rs index f70b6d9..1b9a6bf 100644 --- a/src/worldstate/client.rs +++ b/src/worldstate/client.rs @@ -2,20 +2,57 @@ //! A client to do all sorts of things with the API +use std::{ + any::{ + Any, + TypeId, + type_name, + }, + sync::Arc, + time::Duration, +}; + +use moka::future::Cache; use reqwest::StatusCode; use super::{ Queryable, - TimedEvent, error::Error, language::Language, models::items::Item, - utils::{ - Change, - CrossDiff, - }, }; +#[derive(Debug, Clone)] +pub struct ClientConfig { + /// The time a nested listener should sleep before making another request + pub nested_listener_sleep: Duration, + + /// The time a listener sleeps upon reaching it's expiry until it tries to fetch the updated + /// data + pub listener_sleep_timeout: Duration, + + /// Whether the items cache should create entries of items not found by the API. + /// # Advantage + /// If the same mistake happens multiple times, only a single request will be sent + /// + /// # Disadvantage + /// Can bloat memory usage. + pub cache_404_item_requests: bool, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + nested_listener_sleep: Duration::from_mins(5), + listener_sleep_timeout: Duration::from_mins(5), + cache_404_item_requests: false, + } + } +} + +type TypeCache = Cache<(Language, TypeId), Arc>; +type ItemCache = Cache<(Language, Box), Option>; + /// The client that acts as a convenient way to query models. /// /// ## Example @@ -43,8 +80,11 @@ use super::{ /// Check the [queryable](crate::worldstate::queryable) module for all queryable types. #[derive(Debug, Clone)] pub struct Client { - http: reqwest::Client, - base_url: String, + pub(crate) http: reqwest::Client, + pub(crate) base_url: String, + pub(crate) config: ClientConfig, + type_cache: TypeCache, + items_cache: ItemCache, } impl Default for Client { @@ -54,7 +94,14 @@ impl Default for Client { fn default() -> Self { Self { http: reqwest::Client::new(), - base_url: "https://api.warframestat.us".to_string(), + base_url: "https://api.warframestat.us".to_owned(), + config: ClientConfig::default(), + type_cache: Cache::builder() + .time_to_live(Duration::from_mins(5)) + .build(), + items_cache: Cache::builder() + .time_to_live(Duration::from_hours(12)) + .build(), } } } @@ -62,16 +109,48 @@ impl Default for Client { impl Client { /// Creates a new [Client] with the option to supply a custom reqwest client and a base url. #[must_use] - pub fn new(reqwest_client: reqwest::Client, base_url: String) -> Self { + pub fn new( + reqwest_client: reqwest::Client, + base_url: String, + config: ClientConfig, + type_cache: TypeCache, + items_cache: ItemCache, + ) -> Self { Self { http: reqwest_client, base_url, + config, + type_cache, + items_cache, } } -} -// impl FETCH -impl Client { + async fn type_cached(&self, language: Language, fallback: F) -> Result + where + T: Queryable, + F: AsyncFn() -> Result, + { + let type_id = TypeId::of::(); + + if let Some(item) = self + .type_cache + .get(&(language, type_id)) + .await + .and_then(|any| any.downcast_ref::().cloned()) + { + tracing::debug!("cache hit for type {}", type_name::()); + return Ok(item); + } + + let item = fallback().await?; + + self.type_cache + .insert((language, type_id), Arc::new(item.clone())) + .await; + + Ok(item) + } + /// Fetches an instance of a specified model. /// /// # Example @@ -99,7 +178,8 @@ impl Client { where T: Queryable, { - ::query(&self.base_url, &self.http).await + self.type_cached::(Language::EN, || T::query(&self.base_url, &self.http)) + .await } /// Fetches an instance of a specified model in a supplied Language. @@ -131,7 +211,34 @@ impl Client { where T: Queryable, { - T::query_with_language(&self.base_url, &self.http, language).await + self.type_cached::(language, || { + T::query_with_language(&self.base_url, &self.http, language) + }) + .await + } + + async fn cached_item( + &self, + language: Language, + query: &str, + fallback: F, + ) -> Result, Error> + where + F: AsyncFn() -> Result, Error>, + { + let key = (language, Box::from(query)); + if let Some(item) = self.items_cache.get(&key).await { + tracing::debug!("cache hit for {key:?}"); + return Ok(item); + } + + let maybe_item = fallback().await?; + + if maybe_item.is_some() || self.config.cache_404_item_requests { + self.items_cache.insert(key, maybe_item.clone()).await; + } + + Ok(maybe_item) } /// Queries an item by its name and returns the closest matching item. @@ -163,11 +270,13 @@ impl Client { /// } /// ``` pub async fn query_item(&self, query: &str) -> Result, Error> { - self.query_by_url(format!( - "{}/items/{}/?language=en", - self.base_url, - urlencoding::encode(query), - )) + self.cached_item(Language::EN, query, || { + self.query_by_url(format!( + "{}/items/{}/?language=en", + self.base_url, + urlencoding::encode(query), + )) + }) .await } @@ -201,12 +310,14 @@ impl Client { query: &str, language: Language, ) -> Result, Error> { - self.query_by_url(format!( - "{}/items/{}/?language={}", - self.base_url, - urlencoding::encode(query), - language - )) + self.cached_item(language, query, || { + self.query_by_url(format!( + "{}/items/{}/?language={}", + self.base_url, + urlencoding::encode(query), + language + )) + }) .await } @@ -223,383 +334,4 @@ impl Client { Ok(Some(item)) } - - /// Asynchronous method that continuously fetches updates for a given type `T` and invokes a - /// callback function. - /// - /// # Arguments - /// - /// - `callback`: A function that implements the `ListenerCallback` trait and is called with the - /// previous and new values of `T`. - /// - /// # Generic Constraints - /// - /// - `T`: Must implement the `Queryable` and `TimedEvent` traits. - /// - `Callback`: Must implement the `ListenerCallback` trait with a lifetime parameter `'any` - /// and type parameter `T`. - /// - /// # Returns - /// - /// - `Result<(), Error>`: Returns `Ok(())` if the operation is successful, otherwise returns an - /// `Error`. - /// - /// # Example - /// - /// ```rust - /// use std::error::Error; - /// - /// use warframe::worldstate::{ - /// Client, - /// queryable::Cetus, - /// }; - /// - /// async fn on_cetus_update(before: &Cetus, after: &Cetus) { - /// println!("BEFORE : {before:?}"); - /// println!("AFTER : {after:?}"); - /// } - /// - /// #[tokio::main] - /// async fn main() -> Result<(), Box> { - /// let client = Client::default(); - /// - /// client.call_on_update(on_cetus_update); // don't forget to start it as a bg task (or .await it)s - /// Ok(()) - /// } - /// ``` - #[allow(clippy::missing_panics_doc)] - pub async fn call_on_update(&self, callback: Callback) -> Result<(), Error> - where - T: TimedEvent + Queryable, - for<'a, 'b> Callback: AsyncFn(&'a T, &'b T), - { - tracing::debug!("{} (LISTENER) :: Started", std::any::type_name::()); - let mut item = self.fetch::().await?; - - loop { - if item.expiry() <= chrono::offset::Utc::now() { - tracing::debug!( - listener = %std::any::type_name::(), - "(LISTENER) Fetching new possible update" - ); - - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - - let new_item = self.fetch::().await?; - - if item.expiry() >= new_item.expiry() { - continue; - } - - callback(&item, &new_item).await; - item = new_item; - } - - let time_to_sleep = item.expiry() - chrono::offset::Utc::now(); - - tracing::debug!( - listener = %std::any::type_name::(), - sleep_duration = %time_to_sleep.num_seconds(), - "(LISTENER) Sleeping" - ); - - tokio::time::sleep(time_to_sleep.to_std().unwrap()).await; - } - } - - /// Asynchronous method that continuously fetches updates for a given type `T` and invokes a - /// callback function. - /// - /// # Arguments - /// - /// - `callback`: A function that implements the `ListenerCallback` trait and is called with the - /// previous and new values of `T`. - /// - /// # Generic Constraints - /// - /// - `T`: Must implement the `Queryable`, `TimedEvent` and `PartialEq` traits. - /// - `Callback`: Must implement the `ListenerCallback` trait with a lifetime parameter `'any` - /// and type parameter `T`. - /// - /// # Returns - /// - /// - `Result<(), Error>`: Returns `Ok(())` if the operation is successful, otherwise returns an - /// `Error`. - /// - /// # Example - /// - /// ```rust - /// use std::error::Error; - /// - /// use warframe::worldstate::{ - /// Client, - /// Change, - /// queryable::Fissure, - /// }; - /// - /// /// This function will be called once a fissure updates. - /// /// This will send a request to the corresponding endpoint once every 30s - /// /// and compare the results for changes. - /// async fn on_fissure_update(fissure: &Fissure, change: Change) { - /// match change { - /// Change::Added => println!("Fissure ADDED : {fissure:?}"), - /// Change::Removed => println!("Fissure REMOVED : {fissure:?}"), - /// } - /// } - /// - /// #[tokio::main] - /// async fn main() -> Result<(), Box> { - /// // initialize a client (included in the prelude) - /// let client = Client::default(); - /// - /// // Pass the function to the handler - /// // (will return a Future) - /// client.call_on_nested_update(on_fissure_update); // don't forget to start it as a bg task (or .await it) - /// Ok(()) - /// } - /// ``` - #[allow(clippy::missing_panics_doc)] - #[allow(clippy::missing_errors_doc)] - pub async fn call_on_nested_update(&self, callback: Callback) -> Result<(), Error> - where - T: TimedEvent + Queryable> + PartialEq, - for<'any> Callback: AsyncFn(&'any T, Change), - { - tracing::debug!( - listener = %std::any::type_name::>(), - "(LISTENER) Started" - ); - - let mut items = self.fetch::().await?; - - loop { - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - - tracing::debug!( - listener = %std::any::type_name::>(), - "(LISTENER) Fetching new possible state" - ); - - let new_items = self.fetch::().await?; - - let diff = CrossDiff::new(&items, &new_items); - - let removed_items = diff.removed(); - let added_items = diff.added(); - - if !removed_items.is_empty() || !added_items.is_empty() { - tracing::debug!( - listener = %std::any::type_name::>(), - "(LISTENER) Found changes, proceeding to call callback with every change" - ); - - for (item, change) in removed_items.into_iter().chain(added_items) { - // call callback fn - callback(item, change).await; - } - items = new_items; - } - } - } - - /// Asynchronous method that calls a callback function with state on update. - /// - /// # Arguments - /// - /// - `callback`: A callback function that takes the current item, the new item, and the state - /// as arguments. - /// - `state`: The state object that will be passed to the callback function. - /// - /// # Generic Parameters - /// - /// - `S`: The type of the state object. It must be `Sized`, `Send`, `Sync`, and `Clone`. - /// - `T`: Must implement the `Queryable` and `TimedEvent` traits. - /// - `Callback`: The type of the callback function. It must implement the - /// `StatefulListenerCallback` trait with the item type `T` and the state type `S`. - /// - /// # Returns - /// - /// This method returns a `Result` indicating whether the operation was successful or an - /// `Error` occurred. The result is `Ok(())` if the operation was successful. - /// - /// # Examples - /// - /// ```rust - /// use std::{error::Error, sync::Arc}; - /// - /// use warframe::worldstate::{Client, queryable::Cetus}; - /// - /// // Define some state - /// #[derive(Debug)] - /// struct MyState { - /// _num: i32, - /// _s: String, - /// } - /// - /// async fn on_cetus_update(state: Arc, before: &Cetus, after: &Cetus) { - /// println!("STATE : {state:?}"); - /// println!("BEFORE : {before:?}"); - /// println!("AFTER : {after:?}"); - /// } - /// - /// #[tokio::main] - /// async fn main() -> Result<(), Box> { - /// let client = Client::default(); - /// - /// // Note that the state will be cloned into the handler, so Arc is preferred - /// let state = Arc::new(MyState { - /// _num: 69, - /// _s: "My ginormous ass".into(), - /// }); - /// - /// client - /// .call_on_update_with_state(on_cetus_update, state); // don't forget to start it as a bg task (or .await it) - /// Ok(()) - /// } - /// ``` - #[allow(clippy::missing_panics_doc)] - pub async fn call_on_update_with_state( - &self, - callback: Callback, - state: S, - ) -> Result<(), Error> - where - S: Sized + Send + Sync + Clone, - T: TimedEvent + Queryable, - for<'a, 'b> Callback: AsyncFn(S, &'a T, &'b T), - { - let mut item = self.fetch::().await?; - - loop { - if item.expiry() <= chrono::offset::Utc::now() { - tracing::debug!( - listener = %std::any::type_name::(), - "(LISTENER) Fetching new possible state" - ); - - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - - let new_item = self.fetch::().await?; - - if item.expiry() >= new_item.expiry() { - continue; - } - callback(state.clone(), &item, &new_item).await; - item = new_item; - } - - let time_to_sleep = item.expiry() - chrono::offset::Utc::now(); - - tracing::debug!( - listener = %std::any::type_name::(), - sleep_duration = %time_to_sleep.num_seconds(), - "(LISTENER) Sleeping" - ); - - tokio::time::sleep(time_to_sleep.to_std().unwrap()).await; - } - } - - /// Asynchronous method that calls a callback function on nested updates with a given state. - /// - /// # Arguments - /// - /// * `callback` - The callback function to be called on each change. - /// * `state` - The state to be passed to the callback function. - /// - /// # Generic Constraints - /// - /// * `S` - The type of the state, which must be `Sized`, `Send`, `Sync`, and `Clone`. - /// * `T` - Must implement the `Queryable`, `TimedEvent` and `PartialEq` traits. - /// * `Callback` - The type of the callback function, which must implement the - /// `StatefulNestedListenerCallback` trait. - /// - /// # Returns - /// - /// Returns `Ok(())` if the callback function is successfully called on each change, or an - /// `Error` if an error occurs. - /// - /// # Example - /// - /// ```rust - /// use std::{error::Error, sync::Arc}; - /// - /// use warframe::worldstate::{Change, Client, queryable::Fissure}; - /// - /// // Define some state - /// #[derive(Debug)] - /// struct MyState { - /// _num: i32, - /// _s: String, - /// } - /// - /// async fn on_fissure_update(state: Arc, fissure: &Fissure, change: Change) { - /// println!("STATE : {state:?}"); - /// match change { - /// Change::Added => println!("FISSURE ADDED : {fissure:?}"), - /// Change::Removed => println!("FISSURE REMOVED : {fissure:?}"), - /// } - /// } - /// - /// #[tokio::main] - /// async fn main() -> Result<(), Box> { - /// let client = Client::default(); - /// - /// // Note that the state will be cloned into the handler, so Arc is preferred - /// let state = Arc::new(MyState { - /// _num: 69, - /// _s: "My ginormous ass".into(), - /// }); - /// - /// client - /// .call_on_nested_update_with_state(on_fissure_update, state); // don't forget to start it as a bg task (or .await it) - /// Ok(()) - /// } - /// ``` - #[allow(clippy::missing_panics_doc)] - pub async fn call_on_nested_update_with_state( - &self, - callback: Callback, - state: S, - ) -> Result<(), Error> - where - S: Sized + Send + Sync + Clone, - T: Queryable> + TimedEvent + PartialEq, - for<'any> Callback: AsyncFn(S, &'any T, Change), - { - tracing::debug!( - listener = %std::any::type_name::>(), - "(LISTENER) Started" - ); - - let mut items = self.fetch::().await?; - - loop { - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - - tracing::debug!( - listener = %std::any::type_name::>(), - "(LISTENER) Fetching new possible state" - ); - - let new_items = self.fetch::().await?; - - let diff = CrossDiff::new(&items, &new_items); - - let removed_items = diff.removed(); - let added_items = diff.added(); - - if !removed_items.is_empty() || !added_items.is_empty() { - tracing::debug!( - listener = %std::any::type_name::>(), - "(LISTENER) Found changes, proceeding to call callback with every change" - ); - - for (item, change) in removed_items.into_iter().chain(added_items) { - // call callback fn - callback(state.clone(), item, change).await; - } - items = new_items; - } - } - } } diff --git a/src/worldstate/listener.rs b/src/worldstate/listener.rs new file mode 100644 index 0000000..b3db111 --- /dev/null +++ b/src/worldstate/listener.rs @@ -0,0 +1,169 @@ +use chrono::TimeDelta; + +use crate::worldstate::{ + self, + Client, + Queryable, + TimedEvent, +}; + +fn ignore_state(f: F) -> impl for<'a, 'b> AsyncFn((), &'a T, &'b T) +where + F: AsyncFn(&T, &T), +{ + async move |(), before, after| f(before, after).await +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum ListenerError { + Worldstate(#[from] worldstate::Error), + + /// An error raised when [`chrono::TimeDelta::to_std`] fails. + /// + /// # Note + /// This error should in theory never happen. The only time this would fail is when, for some + /// reason, the [`TimedEvent::expiry`] field on [`TimedEvent`]s is negative/in the past. + #[error("Failed to convert time")] + FailedToConvertTime(TimeDelta), +} + +impl Client { + /// Asynchronous method that continuously fetches updates for a given type `T` and invokes a + /// callback function. + /// + /// # Example + /// + /// ```rust + /// use std::error::Error; + /// + /// use warframe::worldstate::{ + /// Client, + /// queryable::Cetus, + /// }; + /// + /// async fn on_cetus_update(before: &Cetus, after: &Cetus) { + /// println!("BEFORE : {before:?}"); + /// println!("AFTER : {after:?}"); + /// } + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let client = Client::default(); + /// + /// client.call_on_update(on_cetus_update); // don't forget to start it as a bg task (or .await it) + /// Ok(()) + /// } + /// ``` + #[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] + pub async fn call_on_update(&self, callback: Callback) -> Result<(), ListenerError> + where + T: TimedEvent + Queryable, + for<'a, 'b> Callback: AsyncFn(&'a T, &'b T), + { + self.call_on_update_inner(ignore_state(callback), ()).await + } + + /// Asynchronous method that calls a callback function with state on update. + /// + /// # Example + /// + /// ``` + /// use std::{error::Error, sync::Arc}; + /// + /// use warframe::worldstate::{Client, queryable::Cetus}; + /// + /// // Define some state + /// #[derive(Debug)] + /// struct MyState { + /// _num: i32, + /// _s: String, + /// } + /// + /// async fn on_cetus_update(state: Arc, before: &Cetus, after: &Cetus) { + /// println!("STATE : {state:?}"); + /// println!("BEFORE : {before:?}"); + /// println!("AFTER : {after:?}"); + /// } + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let client = Client::default(); + /// + /// // Note that the state will be cloned into the handler, so Arc is preferred + /// let state = Arc::new(MyState { + /// _num: 69, + /// _s: "My ginormous ass".into(), + /// }); + /// + /// client + /// .call_on_update_with_state(on_cetus_update, state); // don't forget to start it as a bg task (or .await it) + /// Ok(()) + /// } + /// ``` + #[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] + pub async fn call_on_update_with_state( + &self, + callback: Callback, + state: S, + ) -> Result<(), ListenerError> + where + S: Sized + Send + Sync + Clone, + T: TimedEvent + Queryable, + for<'a, 'b> Callback: AsyncFn(S, &'a T, &'b T), + { + self.call_on_update_inner(callback, state).await + } + + /// A generalized implementation of stateful and non-stateful listeners. + /// + /// # Panics + /// [`chrono::TimeDelta::to_std`] + async fn call_on_update_inner( + &self, + callback: Callback, + state: S, + ) -> Result<(), ListenerError> + where + S: Sized + Send + Sync + Clone, + T: TimedEvent + Queryable, + for<'a, 'b> Callback: AsyncFn(S, &'a T, &'b T), + { + let mut item = self.fetch::().await?; + + loop { + if item.expiry() <= chrono::offset::Utc::now() { + tracing::debug!( + listener = %std::any::type_name::(), + "(LISTENER) Fetching new possible state" + ); + + // A buffer-sleep. The API does NOT update on the minute. + // So we wait again to avoid sending another request too soon + tokio::time::sleep(self.config.listener_sleep_timeout).await; + + let new_item = self.fetch::().await?; + + if item.expiry() >= new_item.expiry() { + continue; + } + callback(state.clone(), &item, &new_item).await; + item = new_item; + } + + let chrono_time_to_sleep = item.expiry() - chrono::Utc::now(); + + let time_to_sleep = chrono_time_to_sleep + .to_std() + .map_err(|_| ListenerError::FailedToConvertTime(chrono_time_to_sleep))?; + + tracing::debug!( + listener = %std::any::type_name::(), + sleep_duration_seconds = %time_to_sleep.as_secs(), + "(LISTENER) Sleeping" + ); + + tokio::time::sleep(time_to_sleep).await; + } + } +} diff --git a/src/worldstate/listener_nested.rs b/src/worldstate/listener_nested.rs new file mode 100644 index 0000000..3aacb5f --- /dev/null +++ b/src/worldstate/listener_nested.rs @@ -0,0 +1,166 @@ +use crate::worldstate::{ + Change, + Client, + Error, + Queryable, + TimedEvent, + utils::CrossDiff, +}; + +fn ignore_state(f: F) -> impl for<'a> AsyncFn((), &'a T, Change) +where + F: AsyncFn(&T, Change), +{ + async move |(), item, change| f(item, change).await +} + +impl Client { + /// Asynchronous method that calls a callback function on nested updates with a given state. + /// Used on types that yield many data at once - such as fissures. + /// + /// # Example + /// + /// ```rust + /// use std::error::Error; + /// + /// use warframe::worldstate::{ + /// Client, + /// Change, + /// queryable::Fissure, + /// }; + /// + /// /// This function will be called once a fissure updates. + /// /// This will send a request to the corresponding endpoint once every 30s + /// /// and compare the results for changes. + /// async fn on_fissure_update(fissure: &Fissure, change: Change) { + /// match change { + /// Change::Added => println!("Fissure ADDED : {fissure:?}"), + /// Change::Removed => println!("Fissure REMOVED : {fissure:?}"), + /// } + /// } + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// // initialize a client (included in the prelude) + /// let client = Client::default(); + /// + /// // Pass the function to the handler + /// // (will return a Future) + /// client.call_on_nested_update(on_fissure_update); // don't forget to start it as a bg task (or .await it) + /// Ok(()) + /// } + /// ``` + #[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] + pub async fn call_on_nested_update(&self, callback: Callback) -> Result<(), Error> + where + T: TimedEvent + Queryable> + PartialEq, + for<'any> Callback: AsyncFn(&'any T, Change), + { + self.call_on_nested_update_inner(ignore_state(callback), ()) + .await + } + + /// Same as [`Client::call_on_nested_update`], but with an additional provided state. + /// + /// # Example + /// + /// ```rust + /// use std::{error::Error, sync::Arc}; + /// + /// use warframe::worldstate::{Change, Client, queryable::Fissure}; + /// + /// // Define some state + /// #[derive(Debug)] + /// struct MyState { + /// _num: i32, + /// _s: String, + /// } + /// + /// async fn on_fissure_update(state: Arc, fissure: &Fissure, change: Change) { + /// println!("STATE : {state:?}"); + /// match change { + /// Change::Added => println!("FISSURE ADDED : {fissure:?}"), + /// Change::Removed => println!("FISSURE REMOVED : {fissure:?}"), + /// } + /// } + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let client = Client::default(); + /// + /// // Note that the state will be cloned into the handler, so Arc is preferred + /// let state = Arc::new(MyState { + /// _num: 69, + /// _s: "My ginormous ass".into(), + /// }); + /// + /// client + /// .call_on_nested_update_with_state(on_fissure_update, state); // don't forget to start it as a bg task (or .await it) + /// Ok(()) + /// } + /// ``` + #[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] + pub async fn call_on_nested_update_with_state( + &self, + callback: Callback, + state: S, + ) -> Result<(), Error> + where + S: Sized + Send + Sync + Clone, + T: Queryable> + TimedEvent + PartialEq, + for<'any> Callback: AsyncFn(S, &'any T, Change), + { + self.call_on_nested_update_inner(callback, state).await + } + + /// A generalized implementation of stateful and non-stateful nested listeners. + async fn call_on_nested_update_inner( + &self, + callback: Callback, + state: S, + ) -> Result<(), Error> + where + S: Sized + Send + Sync + Clone, + T: Queryable> + TimedEvent + PartialEq, + for<'any> Callback: AsyncFn(S, &'any T, Change), + { + tracing::debug!( + listener = %std::any::type_name::>(), + "(LISTENER) Started" + ); + + let mut items = self.fetch::().await?; + + loop { + tokio::time::sleep(self.config.nested_listener_sleep).await; + + tracing::debug!( + listener = %std::any::type_name::>(), + "(LISTENER) Fetching new possible state" + ); + + let new_items = self.fetch::().await?; + + let diff = CrossDiff::new(&items, &new_items); + + let removed_items = diff.removed(); + let added_items = diff.added(); + + if removed_items.is_empty() && added_items.is_empty() { + continue; + } + + tracing::debug!( + listener = %std::any::type_name::>(), + "(LISTENER) Found changes, proceeding to call callback with every change" + ); + + for (item, change) in removed_items.into_iter().chain(added_items) { + // call callback fn + callback(state.clone(), item, change).await; + } + + items = new_items; + } + } +} diff --git a/src/worldstate/mod.rs b/src/worldstate/mod.rs index e599c9f..8be3f69 100644 --- a/src/worldstate/mod.rs +++ b/src/worldstate/mod.rs @@ -30,6 +30,8 @@ mod models; pub mod utils; pub mod language; +mod listener; +mod listener_nested; /// A module that re-exports every type that is queryable pub mod queryable { diff --git a/src/worldstate/models/base.rs b/src/worldstate/models/base.rs index 6ff371b..3d39578 100644 --- a/src/worldstate/models/base.rs +++ b/src/worldstate/models/base.rs @@ -1,6 +1,7 @@ //! Here lies what powers the models. use std::{ + self, fmt::Write, ops::{ Div, @@ -70,9 +71,9 @@ pub trait TimedEvent { /// Marks a struct as `Queryable`. /// /// Comes with a default implementation that works universally. -pub trait Queryable: Endpoint { +pub trait Queryable: Endpoint + Clone + 'static { /// The Type returned by the [query](Queryable::query). - type Return: DeserializeOwned; + type Return: DeserializeOwned + Send + Sync + Clone + 'static; /// Queries a model and returns an instance of [`itself`](Queryable::Return). #[must_use] diff --git a/src/worldstate/models/items/warframe.rs b/src/worldstate/models/items/warframe.rs index 8d6100f..8e563b0 100644 --- a/src/worldstate/models/items/warframe.rs +++ b/src/worldstate/models/items/warframe.rs @@ -3,7 +3,6 @@ use serde::Deserialize; use super::{ - Category, Component, Introduced, Polarity, @@ -18,7 +17,7 @@ pub struct Warframe { pub armor: i64, - pub aura: String, + pub aura: Option, pub build_price: i64, @@ -26,8 +25,6 @@ pub struct Warframe { pub build_time: i64, - pub category: Category, - pub color: i64, pub components: Vec, @@ -83,9 +80,9 @@ pub struct Warframe { pub unique_name: String, - pub wikia_thumbnail: String, + pub wikia_thumbnail: Option, - pub wikia_url: String, + pub wikia_url: Option, } /// An ability diff --git a/src/worldstate/models/items/weapon.rs b/src/worldstate/models/items/weapon.rs index bed45ad..a12e9d6 100644 --- a/src/worldstate/models/items/weapon.rs +++ b/src/worldstate/models/items/weapon.rs @@ -4,7 +4,6 @@ use chrono::NaiveDate; use serde::Deserialize; use super::{ - Category, Component, Introduced, Polarity, @@ -66,8 +65,6 @@ pub struct RangedWeapon { pub build_time: i64, - pub category: Category, - pub components: Vec, pub consume_on_build: bool, @@ -179,8 +176,6 @@ pub struct MeleeWeapon { pub build_time: i64, - pub category: Category, - pub components: Vec, pub consume_on_build: bool, diff --git a/warframe-macros/Cargo.toml b/warframe-macros/Cargo.toml index b3ef4e1..2d5a819 100644 --- a/warframe-macros/Cargo.toml +++ b/warframe-macros/Cargo.toml @@ -16,6 +16,6 @@ proc-macro = true [dependencies] heck = "0.5.0" manyhow = { version = "0.11.4" } -proc-macro2 = { version = "1.0.93" } -quote = { version = "1.0.38" } -syn = { version = "2.0.98", features = ["full"] } +proc-macro2 = { version = "1.0.104" } +quote = { version = "1.0.42" } +syn = { version = "2.0.111", features = ["full"] }