From 529613575b905115a6186c3cf48d764e486c4aa1 Mon Sep 17 00:00:00 2001 From: TotallyNot <44345987+TotallyNot@users.noreply.github.com> Date: Thu, 28 Jul 2022 11:34:48 +0200 Subject: [PATCH] initial commit --- .gitignore | 3 + Cargo.toml | 33 +++++ macros/Cargo.toml | 12 ++ macros/src/lib.rs | 187 +++++++++++++++++++++++++ src/de_util.rs | 62 +++++++++ src/faction.rs | 61 +++++++++ src/lib.rs | 337 ++++++++++++++++++++++++++++++++++++++++++++++ src/user.rs | 193 ++++++++++++++++++++++++++ 8 files changed, 888 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 macros/Cargo.toml create mode 100644 macros/src/lib.rs create mode 100644 src/de_util.rs create mode 100644 src/faction.rs create mode 100644 src/lib.rs create mode 100644 src/user.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffc3118 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/Cargo.lock +.env diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9d3371c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "torn-api" +version = "0.1.0" +edition = "2021" + +[workspace] +members = [ "macros" ] + +[features] +default = [ "reqwest" ] +reqwest = [ "dep:reqwest" ] +awc = [ "dep:awc" ] + +[dependencies] +serde = { version = "1", features = [ "derive" ] } +serde_json = "1" +chrono = { version = "0.4", features = [ "serde" ], default-features = false } +async-trait = "0.1" +thiserror = "1" +num-traits = "0.2" + +reqwest = { version = "0.11", default-features = false, features = [ "json" ], optional = true } +awc = { version = "3", default-features = false, optional = true } + +macros = { path = "macros" } + +[dev-dependencies] +actix-rt = { version = "2.7.0" } +dotenv = "0.15.0" +tokio = { version = "1.20.1", features = ["test-util", "rt", "macros"] } +tokio-test = "0.4.2" +reqwest = { version = "*", default-features = true } +awc = { version = "*", features = [ "rustls" ] } diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..79584de --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "1.0", features = [ "extra-traits" ] } +quote = "1.0" +convert_case = "0.5" diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..964b646 --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,187 @@ +use convert_case::{Case, Casing}; +use proc_macro::TokenStream; +use quote::{format_ident, quote}; + +#[proc_macro_derive(ApiCategory, attributes(api))] +pub fn derive_api_category(input: TokenStream) -> TokenStream { + let ast = syn::parse(input).unwrap(); + + impl_api_category(&ast) +} + +#[derive(Debug)] +enum ApiField { + Property(syn::Ident), + Flattened, +} + +#[derive(Debug)] +struct ApiAttribute { + type_: syn::Ident, + field: ApiField, + name: syn::Ident, + raw_value: String, + variant: syn::Ident, +} + +fn get_lit_string(lit: syn::Lit) -> String { + match lit { + syn::Lit::Str(lit) => lit.value(), + _ => panic!("Expected api attribute to be a string"), + } +} + +fn impl_api_category(ast: &syn::DeriveInput) -> TokenStream { + let name = &ast.ident; + + let enum_ = match &ast.data { + syn::Data::Enum(data) => data, + _ => panic!("ApiCategory can only be derived for enums"), + }; + + let mut category: Option = None; + ast.attrs + .iter() + .filter(|a| a.path.is_ident("api")) + .for_each(|a| { + if let Ok(syn::Meta::List(l)) = a.parse_meta() { + for nested in l.nested { + match nested { + syn::NestedMeta::Meta(syn::Meta::NameValue(m)) + if m.path.is_ident("category") => + { + category = Some(get_lit_string(m.lit)) + } + _ => panic!("unknown api attribute"), + } + } + } + }); + + let category = category.expect("`category`"); + + let fields: Vec<_> = enum_ + .variants + .iter() + .filter_map(|variant| { + for attr in &variant.attrs { + if attr.path.is_ident("api") { + let meta = attr.parse_meta(); + match meta { + Ok(syn::Meta::List(l)) => { + let mut type_: Option = None; + let mut field: Option = None; + for nested in l.nested.into_iter() { + match nested { + syn::NestedMeta::Meta(syn::Meta::NameValue(m)) + if m.path.is_ident("type") => + { + if type_.is_none() { + type_ = Some(get_lit_string(m.lit)); + } else { + panic!("type can only be specified once"); + } + } + syn::NestedMeta::Meta(syn::Meta::NameValue(m)) + if m.path.is_ident("field") => + { + if field.is_none() { + field = Some(ApiField::Property(quote::format_ident!( + "{}", + get_lit_string(m.lit) + ))); + } else { + panic!("field/flatten can only be specified once"); + } + } + syn::NestedMeta::Meta(syn::Meta::Path(m)) + if m.is_ident("flatten") => + { + if field.is_none() { + field = Some(ApiField::Flattened); + } else { + panic!("field/flatten can only be specified once"); + } + } + _ => panic!("Couldn't parse api attribute"), + } + } + let name = + format_ident!("{}", variant.ident.to_string().to_case(Case::Snake)); + let raw_value = variant.ident.to_string().to_lowercase(); + return Some(ApiAttribute { + type_: quote::format_ident!("{}", type_.expect("type")), + field: field.expect("one of field/flatten"), + name, + raw_value, + variant: variant.ident.clone(), + }); + } + _ => panic!("Couldn't parse api attribute"), + } + } + } + None + }) + .collect(); + + let accessors = fields.iter().map( + |ApiAttribute { + type_, field, name, .. + }| match field { + ApiField::Property(prop) => { + let prop_str = prop.to_string(); + quote! { + pub fn #name(&self) -> serde_json::Result<#type_> { + self.0.decode_field(#prop_str) + } + } + } + ApiField::Flattened => quote! { + pub fn #name(&self) -> serde_json::Result<#type_> { + self.0.decode() + } + }, + }, + ); + + let raw_values = fields.iter().map( + |ApiAttribute { + variant, raw_value, .. + }| { + quote! { + #name::#variant => #raw_value + } + }, + ); + + let gen = quote! { + pub struct Response(crate::ApiResponse); + + impl Response { + #(#accessors)* + } + + impl crate::ApiCategoryResponse for Response { + type Selection = #name; + + fn from_response(response: crate::ApiResponse) -> Self { + Self(response) + } + } + + impl crate::ApiSelection for #name { + fn raw_value(&self) -> &'static str { + match self { + #(#raw_values,)* + } + } + + fn category() -> &'static str { + #category + } + } + }; + + gen.into() +} diff --git a/src/de_util.rs b/src/de_util.rs new file mode 100644 index 0000000..6a6056d --- /dev/null +++ b/src/de_util.rs @@ -0,0 +1,62 @@ +use chrono::{DateTime, NaiveDateTime, Utc}; +use num_traits::{PrimInt, Zero}; +use serde::de::{Deserialize, Deserializer, Error, Unexpected}; + +pub fn empty_string_is_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + if s.is_empty() { + Ok(None) + } else { + Ok(Some(s)) + } +} + +pub fn string_is_long<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + s.parse() + .map_err(|_e| Error::invalid_type(Unexpected::Str(&s), &"i64")) +} + +pub fn zero_date_is_none<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let i = i64::deserialize(deserializer)?; + if i == 0 { + Ok(None) + } else { + let naive = NaiveDateTime::from_timestamp(i, 0); + Ok(Some(DateTime::from_utc(naive, Utc))) + } +} + +pub fn zero_is_none<'de, D, I>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + I: PrimInt + Zero + Deserialize<'de>, +{ + let i = I::deserialize(deserializer)?; + if i == I::zero() { + Ok(None) + } else { + Ok(Some(i)) + } +} + +pub fn none_is_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + if s == "None" { + Ok(None) + } else { + Ok(Some(s)) + } +} diff --git a/src/faction.rs b/src/faction.rs new file mode 100644 index 0000000..a675844 --- /dev/null +++ b/src/faction.rs @@ -0,0 +1,61 @@ +use std::collections::BTreeMap; + +use chrono::{serde::ts_seconds, DateTime, Utc}; +use serde::Deserialize; + +use macros::ApiCategory; + +use super::de_util; + +#[derive(Debug, Clone, Copy, ApiCategory)] +#[api(category = "faction")] +pub enum Selection { + #[api(type = "Basic", flatten)] + Basic, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Member { + pub name: String, + pub level: i16, + pub days_in_faction: i16, + pub position: String, + pub status: super::user::Status, + pub last_action: super::user::LastAction, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Basic { + #[serde(rename = "ID")] + pub id: i32, + pub name: String, + pub leader: i32, + + pub respect: i32, + pub age: i16, + pub capacity: i16, + pub best_chain: i32, + + pub members: BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{tests::{setup, Client, async_test}, ApiClient}; + + #[async_test] + async fn faction() { + let key = setup(); + + let response = Client::default() + .torn_api(key) + .faction(None) + .selections(&[Selection::Basic]) + .send() + .await + .unwrap(); + + response.basic().unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..319c8a1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,337 @@ +#![warn(clippy::all, clippy::perf, clippy::pedantic, clippy::suspicious)] + +pub mod user; +pub mod faction; + +mod de_util; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::de::{DeserializeOwned, Error as DeError}; +use thiserror::Error; + + +#[derive(Error, Debug)] +pub enum Error { + #[error("api returned error '{reason}', code = '{code}'")] + Api { code: u8, reason: String }, + + #[cfg(feature = "reqwest")] + #[error("api request failed with network error")] + Reqwest(#[from] reqwest::Error), + + #[cfg(feature = "awc")] + #[error("api request failed with network error")] + AwcSend(#[from] awc::error::SendRequestError), + + #[cfg(feature = "awc")] + #[error("api request failed to read payload")] + AwcPayload(#[from] awc::error::JsonPayloadError), + + #[error("api response couldn't be deserialized")] + Deserialize(#[from] serde_json::Error), +} + +pub struct ApiResponse { + value: serde_json::Value, +} + +impl ApiResponse { + fn from_value(mut value: serde_json::Value) -> Result { + #[derive(serde::Deserialize)] + struct ApiErrorDto { + code: u8, + #[serde(rename = "error")] + reason: String, + } + match value.get_mut("error") { + Some(error) => { + let dto: ApiErrorDto = serde_json::from_value(error.take())?; + Err(Error::Api { + code: dto.code, + reason: dto.reason, + }) + } + None => Ok(Self { value }), + } + } + + fn decode(&self) -> serde_json::Result + where + D: DeserializeOwned, + { + serde_json::from_value(self.value.clone()) + } + + fn decode_field(&self, field: &'static str) -> serde_json::Result + where + D: DeserializeOwned, + { + let value = self + .value + .get(field) + .ok_or_else(|| serde_json::Error::missing_field(field))? + .clone(); + + serde_json::from_value(value) + } +} + +pub trait ApiSelection { + fn raw_value(&self) -> &'static str; + + fn category() -> &'static str; +} + +pub trait ApiCategoryResponse { + type Selection: ApiSelection; + + fn from_response(response: ApiResponse) -> Self; +} + +#[async_trait(?Send)] +pub trait ApiClient { + async fn request(&self, url: String) -> Result; + + fn torn_api(&self, key: String) -> TornApi + where + Self: Sized; +} + +#[cfg(feature = "reqwest")] +#[async_trait(?Send)] +impl crate::ApiClient for ::reqwest::Client { + async fn request(&self, url: String) -> Result { + let value = self.get(url).send().await?.json().await?; + Ok(value) + } + + fn torn_api(&self, key: String) -> crate::TornApi + where + Self: Sized, + { + crate::TornApi::from_client(self, key) + } +} + +#[cfg(feature = "awc")] +#[async_trait(?Send)] +impl crate::ApiClient for awc::Client { + async fn request(&self, url: String) -> Result { + let value = self.get(url).send().await?.json().await?; + Ok(value) + } + + fn torn_api(&self, key: String) -> crate::TornApi + where + Self: Sized, + { + crate::TornApi::from_client(self, key) + } +} + +pub struct TornApi<'client, C> +where + C: ApiClient, +{ + client: &'client C, + key: String, +} + +impl<'client, C> TornApi<'client, C> +where + C: ApiClient, +{ + #[allow(dead_code)] + pub(crate) fn from_client(client: &'client C, key: String) -> Self { + Self { client, key } + } + + #[must_use] + pub fn user(self, id: Option) -> ApiRequestBuilder<'client, C, user::Response> { + ApiRequestBuilder::new(self.client, self.key, id) + } + + #[must_use] + pub fn faction(self, id: Option) -> ApiRequestBuilder<'client, C, faction::Response> { + ApiRequestBuilder::new(self.client, self.key, id) + } +} + +pub struct ApiRequestBuilder<'client, C, A> +where + C: ApiClient, + A: ApiCategoryResponse, +{ + client: &'client C, + key: String, + phantom: std::marker::PhantomData, + selections: Vec<&'static str>, + id: Option, + from: Option>, + to: Option>, + comment: Option, +} + +impl<'client, C, A> ApiRequestBuilder<'client, C, A> +where + C: ApiClient, + A: ApiCategoryResponse, +{ + pub(crate) fn new(client: &'client C, key: String, id: Option) -> Self { + Self { + client, + key, + phantom: std::marker::PhantomData, + selections: Vec::new(), + id, + from: None, + to: None, + comment: None, + } + } + + #[must_use] + pub fn selections(mut self, selections: &[A::Selection]) -> Self { + self.selections + .append(&mut selections.iter().map(ApiSelection::raw_value).collect()); + self + } + + #[must_use] + pub fn from(mut self, from: DateTime) -> Self { + self.from = Some(from); + self + } + + #[must_use] + pub fn to(mut self, to: DateTime) -> Self { + self.to = Some(to); + self + } + + #[must_use] + pub fn comment(mut self, comment: String) -> Self { + self.comment = Some(comment); + self + } + + /// Executes the api request. + /// + /// # Examples + /// + /// ```no_run + /// use torn_api::{ApiClient, Error}; + /// use reqwest::Client; + /// # async { + /// + /// let key = "XXXXXXXXX".to_owned(); + /// let response = Client::new() + /// .torn_api(key) + /// .user(None) + /// .send() + /// .await; + /// + /// // invalid key + /// assert!(matches!(response, Err(Error::Api { code: 2, .. }))); + /// # }; + /// ``` + /// + /// # Errors + /// + /// Will return an `Err` if the API returns an API error, the request fails due to a network + /// error, or if the response body doesn't contain valid json. + pub async fn send(self) -> Result { + let mut query_fragments = vec![ + format!("selections={}", self.selections.join(",")), + format!("key={}", self.key), + ]; + + if let Some(from) = self.from { + query_fragments.push(format!("from={}", from.timestamp())); + } + + if let Some(to) = self.to { + query_fragments.push(format!("to={}", to.timestamp())); + } + + if let Some(comment) = self.comment { + query_fragments.push(format!("comment={}", comment)); + } + + let query = query_fragments.join("&"); + + let id_fragment = match self.id { + Some(id) => id.to_string(), + None => "".to_owned(), + }; + + let url = format!( + "https://api.torn.com/{}/{}?{}", + A::Selection::category(), + id_fragment, + query + ); + + let value = self.client.request(url).await?; + + ApiResponse::from_value(value).map(A::from_response) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::Once; + + #[cfg(feature = "reqwest")] + pub use reqwest::Client; + #[cfg(all(not(feature = "reqwest"), feature = "awc"))] + pub use awc::Client; + + #[cfg(feature = "reqwest")] + pub use tokio::test as async_test; + #[cfg(all(not(feature = "reqwest"), feature = "awc"))] + pub use actix_rt::test as async_test; + + use super::*; + + static INIT: Once = Once::new(); + + pub(crate) fn setup() -> String { + INIT.call_once(|| { + dotenv::dotenv().ok(); + }); + std::env::var("APIKEY").expect("api key") + } + + #[test] + fn selection_raw_value() { + assert_eq!(user::Selection::Basic.raw_value(), "basic"); + } + + #[cfg(feature = "reqwest")] + #[tokio::test] + async fn reqwest() { + let key = setup(); + + reqwest::Client::default() + .torn_api(key) + .user(None) + .send() + .await + .unwrap(); + } + + #[cfg(feature = "awc")] + #[actix_rt::test] + async fn awc() { + let key = setup(); + + awc::Client::default() + .torn_api(key) + .user(None) + .send() + .await + .unwrap(); + } +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..56dac68 --- /dev/null +++ b/src/user.rs @@ -0,0 +1,193 @@ +use chrono::{serde::ts_seconds, DateTime, Utc}; +use serde::Deserialize; + +use macros::ApiCategory; + +use super::de_util; + +#[derive(Debug, Clone, Copy, ApiCategory)] +#[api(category = "user")] +pub enum Selection { + #[api(type = "Basic", flatten)] + Basic, + #[api(type = "Profile", flatten)] + Profile, + #[api(type = "Discord", field = "discord")] + Discord, + #[api(type = "PersonalStats", field = "personalstats")] + PersonalStats, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub enum Gender { + Male, + Female, + Enby, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LastAction { + #[serde(with = "ts_seconds")] + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Faction { + #[serde(deserialize_with = "de_util::zero_is_none")] + pub faction_id: Option, + #[serde(deserialize_with = "de_util::none_is_none")] + pub faction_name: Option, + #[serde(deserialize_with = "de_util::zero_is_none")] + pub days_in_faction: Option, + #[serde(deserialize_with = "de_util::none_is_none")] + pub position: Option, + pub faction_tag: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub enum State { + Okay, + Traveling, + Hospital, + Abroad, + Jail, + Federal, + Fallen, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StateColour { + Green, + Red, + Blue, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Status { + pub description: String, + #[serde(deserialize_with = "de_util::empty_string_is_none")] + pub details: Option, + #[serde(rename = "color")] + pub colour: StateColour, + pub state: State, + #[serde(deserialize_with = "de_util::zero_date_is_none")] + pub until: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Basic { + pub player_id: i32, + pub name: String, + pub level: i16, + pub gender: Gender, + pub status: Status, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Discord { + #[serde(rename = "userID")] + pub user_id: i32, + #[serde(rename = "discordID", deserialize_with = "de_util::string_is_long")] + pub discord_id: i64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LifeBar { + pub current: i16, + pub maximum: i16, + pub increment: i16, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Profile { + pub player_id: i32, + pub name: String, + pub rank: String, + pub level: i16, + pub gender: Gender, + pub age: i32, + + pub life: LifeBar, + pub last_action: LastAction, + pub faction: Faction, + pub status: Status, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PersonalStats { + #[serde(rename = "attackswon")] + pub attacks_won: i32, + #[serde(rename = "attackslost")] + pub attacks_lost: i32, + #[serde(rename = "defendswon")] + pub defends_won: i32, + #[serde(rename = "defendslost")] + pub defends_lost: i32, + #[serde(rename = "statenhancersused")] + pub stat_enhancers_used: i32, + pub refills: i32, + #[serde(rename = "drugsused")] + pub drugs_used: i32, + #[serde(rename = "xantaken")] + pub xanax_taken: i32, + #[serde(rename = "lsdtaken")] + pub lsd_taken: i32, + #[serde(rename = "networth")] + pub net_worth: i64, + #[serde(rename = "energydrinkused")] + pub cans_used: i32, + #[serde(rename = "boostersused")] + pub boosters_used: i32, + pub awards: i16, + pub elo: i16, + #[serde(rename = "daysbeendonator")] + pub days_been_donator: i16, + #[serde(rename = "bestdamage")] + pub best_damage: i32, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{tests::{setup, Client, async_test}, ApiClient}; + + #[async_test] + async fn user() { + let key = setup(); + + let response = Client::default() + .torn_api(key) + .user(None) + .selections(&[Selection::Basic, Selection::Discord, Selection::Profile, Selection::PersonalStats]) + .send() + .await + .unwrap(); + + response.basic().unwrap(); + response.discord().unwrap(); + response.profile().unwrap(); + response.personal_stats().unwrap(); + } + + #[async_test] + async fn not_in_faction() { + let key = setup(); + + let response = Client::default() + .torn_api(key) + .user(Some(28)) + .selections(&[ Selection::Profile]) + .send() + .await + .unwrap(); + + let faction = response.profile().unwrap().faction; + + assert!(faction.faction_id.is_none()); + assert!(faction.faction_name.is_none()); + assert!(faction.faction_tag.is_none()); + assert!(faction.days_in_faction.is_none()); + assert!(faction.position.is_none()); + } +}