diff --git a/lib/nexusmods/Cargo.toml b/lib/nexusmods/Cargo.toml new file mode 100644 index 0000000..93dbd78 --- /dev/null +++ b/lib/nexusmods/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nexusmods" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +lazy_static = "1.4.0" +reqwest = { version = "0.11.14" } +serde = { version = "1.0.152", features = ["derive"] } +serde_json = "1.0.94" +thiserror = "1.0.39" +time = { version = "0.3.20", features = ["serde"] } +tracing = "0.1.37" +url = "2.3.1" + +[dev-dependencies] +tokio = { version = "1.26.0", features = ["rt", "macros"] } diff --git a/lib/nexusmods/src/lib.rs b/lib/nexusmods/src/lib.rs new file mode 100644 index 0000000..c8ea2a6 --- /dev/null +++ b/lib/nexusmods/src/lib.rs @@ -0,0 +1,132 @@ +use std::convert::Infallible; + +use lazy_static::lazy_static; +use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue}; +use reqwest::{Client, Url}; +use serde::ser::SerializeTuple; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; + +// TODO: Add OS information +const USER_AGENT: &str = concat!("DTMM/", env!("CARGO_PKG_VERSION")); +const GAME_ID: &str = "warhammer40kdarktide"; + +lazy_static! { + static ref BASE_URL: Url = Url::parse("https://api.nexusmods.com/v1/").unwrap(); + static ref BASE_URL_GAME: Url = + Url::parse("https://api.nexusmods.com/v1/games/warhammer40kdarktide/").unwrap(); +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("HTTP error: {0:?}")] + HTTP(#[from] reqwest::Error), + #[error("invalid URL: {0:?}")] + URLParseError(#[from] url::ParseError), + #[error("failed to deserialize '{error}': {json}")] + Deserialize { + json: String, + error: serde_json::Error, + }, + #[error(transparent)] + InvalidHeaderValue(#[from] InvalidHeaderValue), + #[error("this error cannot happen")] + Infallible(#[from] Infallible), +} + +pub type Result = std::result::Result; + +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateInfo { + pub mod_id: u64, + #[serde(with = "time::serde::timestamp")] + pub latest_file_update: OffsetDateTime, + #[serde(with = "time::serde::timestamp")] + pub latest_mod_activity: OffsetDateTime, +} + +#[derive(Copy, Clone, Debug)] +pub enum UpdatePeriod { + Day, + Week, + Month, +} + +impl Default for UpdatePeriod { + fn default() -> Self { + Self::Week + } +} + +impl Serialize for UpdatePeriod { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + let mut tup = serializer.serialize_tuple(2)?; + tup.serialize_element("period")?; + tup.serialize_element(match self { + Self::Day => "1d", + Self::Week => "1w", + Self::Month => "1m", + })?; + tup.end() + } +} + +pub struct Api { + client: Client, +} + +impl Api { + pub fn new(key: String) -> Result { + let mut headers = HeaderMap::new(); + headers.insert("accept", HeaderValue::from_static("application/json")); + headers.insert("apikey", HeaderValue::from_str(&key)?); + + let client = Client::builder() + .user_agent(USER_AGENT) + .default_headers(headers) + .build()?; + + Ok(Self { client }) + } + + #[tracing::instrument(skip(self))] + pub async fn mods_updated(&self, period: UpdatePeriod) -> Result> { + let url = BASE_URL_GAME.join("mods/updated.json")?; + + let res = self + .client + .get(url) + .query(&[period]) + .send() + .await? + .error_for_status()?; + + tracing::trace!(?res); + let json = res.text().await?; + + serde_json::from_str(&json).map_err(|error| Error::Deserialize { json, error }) + } +} + +#[cfg(test)] +mod test { + use crate::Api; + + fn make_api() -> Api { + let key = std::env::var("NEXUSMODS_API_KEY").expect("'NEXUSMODS_API_KEY' env var missing"); + Api::new(key).expect("failed to build API client") + } + + #[tokio::test] + async fn mods_updated() { + let client = make_api(); + client + .mods_updated(Default::default()) + .await + .expect("failed to query 'mods_updated'"); + } +}