This commit is contained in:
TotallyNot 2022-08-31 23:00:25 +02:00
parent fdd2bc18dc
commit ce0dcc28cd
10 changed files with 561 additions and 136 deletions

30
torn-api/Cargo.toml Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "torn-api"
version = "0.2.0"
edition = "2021"
[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" ] }

62
torn-api/src/de_util.rs Normal file
View 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
torn-api/src/faction.rs Normal file
View file

@ -0,0 +1,61 @@
use std::collections::BTreeMap;
use serde::Deserialize;
use macros::ApiCategory;
#[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::{
prelude::*,
tests::{async_test, setup, Client},
};
#[async_test]
async fn faction() {
let key = setup();
let response = Client::default()
.torn_api(key)
.faction()
.selections(&[Selection::Basic])
.send()
.await
.unwrap();
response.basic().unwrap();
}
}

402
torn-api/src/lib.rs Normal file
View file

@ -0,0 +1,402 @@
#![warn(clippy::all, clippy::perf, clippy::style, clippy::suspicious)]
pub mod faction;
pub mod user;
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<ApiResponse, Error>;
}
pub trait DirectApiClient: ApiClient {
fn torn_api(&self, key: String) -> DirectExecutor<Self>
where
Self: Sized,
{
DirectExecutor::from_client(self, key)
}
}
pub trait BackedApiClient: ApiClient {}
#[cfg(feature = "reqwest")]
#[async_trait(?Send)]
impl crate::ApiClient for reqwest::Client {
async fn request(&self, url: String) -> Result<ApiResponse, crate::Error> {
let value: serde_json::Value = self.get(url).send().await?.json().await?;
Ok(ApiResponse::from_value(value)?)
}
}
#[cfg(feature = "reqwest")]
#[async_trait(?Send)]
impl crate::DirectApiClient for reqwest::Client {}
#[cfg(feature = "awc")]
#[async_trait(?Send)]
impl crate::ApiClient for awc::Client {
async fn request(&self, url: String) -> Result<ApiResponse, crate::Error> {
let value: serde_json::Value = self.get(url).send().await?.json().await?;
Ok(ApiResponse::from_value(value)?)
}
}
#[cfg(feature = "awc")]
#[async_trait(?Send)]
impl crate::DirectApiClient for awc::Client {}
#[async_trait(?Send)]
pub trait ApiRequestExecutor<'client> {
type Err: std::error::Error;
async fn excute<A>(&self, request: ApiRequest<A>) -> Result<A, Self::Err>
where
A: ApiCategoryResponse;
#[must_use]
fn user<'executor>(
&'executor self,
) -> ApiRequestBuilder<'client, 'executor, Self, user::Response> {
ApiRequestBuilder::new(self)
}
#[must_use]
fn faction<'executor>(
&'executor self,
) -> ApiRequestBuilder<'client, 'executor, Self, faction::Response> {
ApiRequestBuilder::new(self)
}
}
pub struct DirectExecutor<'client, C>
where
C: ApiClient,
{
client: &'client C,
key: String,
}
impl<'client, C> DirectExecutor<'client, C>
where
C: ApiClient,
{
#[allow(dead_code)]
pub(crate) fn from_client(client: &'client C, key: String) -> Self {
Self { client, key }
}
}
#[async_trait(?Send)]
impl<'client, C> ApiRequestExecutor<'client> for DirectExecutor<'client, C>
where
C: ApiClient,
{
type Err = Error;
async fn excute<A>(&self, request: ApiRequest<A>) -> Result<A, Self::Err>
where
A: ApiCategoryResponse,
{
let url = request.url(&self.key);
self.client.request(url).await.map(A::from_response)
}
}
#[derive(Debug)]
pub struct ApiRequest<A>
where
A: ApiCategoryResponse,
{
selections: Vec<&'static str>,
id: Option<u64>,
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
comment: Option<String>,
phantom: std::marker::PhantomData<A>,
}
impl<A> std::default::Default for ApiRequest<A>
where
A: ApiCategoryResponse,
{
fn default() -> Self {
Self {
selections: Vec::default(),
id: None,
from: None,
to: None,
comment: None,
phantom: std::marker::PhantomData::default(),
}
}
}
impl<A> ApiRequest<A>
where
A: ApiCategoryResponse,
{
pub fn url(&self, key: &str) -> String {
let mut query_fragments = vec![
format!("selections={}", self.selections.join(",")),
format!("key={}", 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(),
};
format!(
"https://api.torn.com/{}/{}?{}",
A::Selection::category(),
id_fragment,
query
)
}
}
pub struct ApiRequestBuilder<'client, 'executor, E, A>
where
E: ApiRequestExecutor<'client> + ?Sized,
A: ApiCategoryResponse,
{
executor: &'executor E,
request: ApiRequest<A>,
_phantom: std::marker::PhantomData<&'client E>,
}
impl<'client, 'executor, E, A> ApiRequestBuilder<'client, 'executor, E, A>
where
E: ApiRequestExecutor<'client> + ?Sized,
A: ApiCategoryResponse,
{
pub(crate) fn new(executor: &'executor E) -> Self {
Self {
executor,
request: ApiRequest::default(),
_phantom: std::marker::PhantomData::default(),
}
}
#[must_use]
pub fn id(mut self, id: u64) -> Self {
self.request.id = Some(id);
self
}
#[must_use]
pub fn selections(mut self, selections: &[A::Selection]) -> Self {
self.request
.selections
.append(&mut selections.iter().map(ApiSelection::raw_value).collect());
self
}
#[must_use]
pub fn from(mut self, from: DateTime<Utc>) -> Self {
self.request.from = Some(from);
self
}
#[must_use]
pub fn to(mut self, to: DateTime<Utc>) -> Self {
self.request.to = Some(to);
self
}
#[must_use]
pub fn comment(mut self, comment: String) -> Self {
self.request.comment = Some(comment);
self
}
/// Executes the api request.
///
/// # Examples
///
/// ```no_run
/// use torn_api::{prelude::*, Error};
/// use reqwest::Client;
/// # async {
///
/// let key = "XXXXXXXXX".to_owned();
/// let response = Client::new()
/// .torn_api(key)
/// .user()
/// .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, <E as ApiRequestExecutor<'client>>::Err> {
self.executor.excute(self.request).await
}
}
pub mod prelude {
pub use super::{ApiClient, ApiRequestExecutor, DirectApiClient};
}
#[cfg(test)]
pub(crate) mod tests {
use std::sync::Once;
#[cfg(all(not(feature = "reqwest"), feature = "awc"))]
pub use awc::Client;
#[cfg(feature = "reqwest")]
pub use reqwest::Client;
#[cfg(all(not(feature = "reqwest"), feature = "awc"))]
pub use actix_rt::test as async_test;
#[cfg(feature = "reqwest")]
pub use tokio::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()
.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();
}
}

202
torn-api/src/user.rs Normal file
View file

@ -0,0 +1,202 @@
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::{
prelude::*,
tests::{async_test, setup, Client},
};
#[async_test]
async fn user() {
let key = setup();
let response = Client::default()
.torn_api(key)
.user()
.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()
.id(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());
}
}