initial commit
This commit is contained in:
commit
529613575b
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
/Cargo.lock
|
||||||
|
.env
|
33
Cargo.toml
Normal file
33
Cargo.toml
Normal file
|
@ -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" ] }
|
12
macros/Cargo.toml
Normal file
12
macros/Cargo.toml
Normal file
|
@ -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"
|
187
macros/src/lib.rs
Normal file
187
macros/src/lib.rs
Normal file
|
@ -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<String> = 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<String> = None;
|
||||||
|
let mut field: Option<ApiField> = 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()
|
||||||
|
}
|
62
src/de_util.rs
Normal file
62
src/de_util.rs
Normal file
|
@ -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<Option<String>, 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<i64, D::Error>
|
||||||
|
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<Option<DateTime<Utc>>, 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<Option<I>, 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<Option<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
if s == "None" {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(s))
|
||||||
|
}
|
||||||
|
}
|
61
src/faction.rs
Normal file
61
src/faction.rs
Normal file
|
@ -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<i32, Member>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
}
|
||||||
|
}
|
337
src/lib.rs
Normal file
337
src/lib.rs
Normal file
|
@ -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<Self, Error> {
|
||||||
|
#[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<D>(&self) -> serde_json::Result<D>
|
||||||
|
where
|
||||||
|
D: DeserializeOwned,
|
||||||
|
{
|
||||||
|
serde_json::from_value(self.value.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_field<D>(&self, field: &'static str) -> serde_json::Result<D>
|
||||||
|
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<serde_json::Value, Error>;
|
||||||
|
|
||||||
|
fn torn_api(&self, key: String) -> TornApi<Self>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "reqwest")]
|
||||||
|
#[async_trait(?Send)]
|
||||||
|
impl crate::ApiClient for ::reqwest::Client {
|
||||||
|
async fn request(&self, url: String) -> Result<serde_json::Value, crate::Error> {
|
||||||
|
let value = self.get(url).send().await?.json().await?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn torn_api(&self, key: String) -> crate::TornApi<Self>
|
||||||
|
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<serde_json::Value, crate::Error> {
|
||||||
|
let value = self.get(url).send().await?.json().await?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn torn_api(&self, key: String) -> crate::TornApi<Self>
|
||||||
|
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<u64>) -> ApiRequestBuilder<'client, C, user::Response> {
|
||||||
|
ApiRequestBuilder::new(self.client, self.key, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn faction(self, id: Option<u64>) -> 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<A>,
|
||||||
|
selections: Vec<&'static str>,
|
||||||
|
id: Option<u64>,
|
||||||
|
from: Option<DateTime<Utc>>,
|
||||||
|
to: Option<DateTime<Utc>>,
|
||||||
|
comment: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'client, C, A> ApiRequestBuilder<'client, C, A>
|
||||||
|
where
|
||||||
|
C: ApiClient,
|
||||||
|
A: ApiCategoryResponse,
|
||||||
|
{
|
||||||
|
pub(crate) fn new(client: &'client C, key: String, id: Option<u64>) -> 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<Utc>) -> Self {
|
||||||
|
self.from = Some(from);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn to(mut self, to: DateTime<Utc>) -> 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<A, Error> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
193
src/user.rs
Normal file
193
src/user.rs
Normal file
|
@ -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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Faction {
|
||||||
|
#[serde(deserialize_with = "de_util::zero_is_none")]
|
||||||
|
pub faction_id: Option<i32>,
|
||||||
|
#[serde(deserialize_with = "de_util::none_is_none")]
|
||||||
|
pub faction_name: Option<String>,
|
||||||
|
#[serde(deserialize_with = "de_util::zero_is_none")]
|
||||||
|
pub days_in_faction: Option<i16>,
|
||||||
|
#[serde(deserialize_with = "de_util::none_is_none")]
|
||||||
|
pub position: Option<String>,
|
||||||
|
pub faction_tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(rename = "color")]
|
||||||
|
pub colour: StateColour,
|
||||||
|
pub state: State,
|
||||||
|
#[serde(deserialize_with = "de_util::zero_date_is_none")]
|
||||||
|
pub until: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue