feat(v2): initial commit

This commit is contained in:
TotallyNot 2025-04-24 13:32:02 +02:00
parent 48868983b3
commit 5a84558d89
44 changed files with 20091 additions and 3489 deletions

12
.gitignore vendored
View file

@ -1,4 +1,12 @@
# rust
/target
/Cargo.lock
.env
# direnv
.envrc
.direnv
# vim
*.swp
# mac os
.DS_Store

1746
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,12 @@
[workspace]
resolver = "2"
members = [ "torn-api-macros", "torn-api", "torn-key-pool" ]
members = ["torn-api", "torn-api-codegen"]
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1" }
syn = { version = "2" }
proc-macro2 = { version = "1" }
[profile.dev.package.torn-api-codegen]
opt-level = 3

114
flake.lock Normal file
View file

@ -0,0 +1,114 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1744618085,
"narHash": "sha256-+VdhZsIiIDtyOL88c4U/Os1PsCMLOCyScIeWL4hxJRM=",
"owner": "nix-community",
"repo": "fenix",
"rev": "a85d390a5607188dca2dbc39b5b37571651d69ce",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1744463964,
"narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744463964,
"narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1744539868,
"narHash": "sha256-NPUnfDAwLD69aKetxjC7lV5ysrvs1IKC0Sy4Zai10Mw=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "8365cf853e791c93fa8bc924f031f11949bb1a3c",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

32
flake.nix Normal file
View file

@ -0,0 +1,32 @@
{
description = "A very basic flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
fenix.url = "github:nix-community/fenix";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
nixpkgs,
fenix,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
toolchain = fenix.packages.${system}.stable.toolchain;
in
{
devShells.default = pkgs.mkShell {
packages = [
toolchain
];
};
}
);
}

View file

@ -0,0 +1,15 @@
[package]
name = "torn-api-codegen"
authors = ["Pyrit [2111649]"]
version = "0.1.0"
edition = "2024"
description = "Contains the v2 torn API model descriptions and codegen for the bindings"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
heck = "0.5"
indexmap = { version = "2.9", features = ["serde"] }
quote = "1"
proc-macro2 = { workspace = true }
syn = { workspace = true }

14602
torn-api-codegen/openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,2 @@
pub mod model;
pub mod openapi;

View file

@ -0,0 +1,310 @@
use heck::ToUpperCamelCase;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use crate::openapi::{
parameter::OpenApiParameterSchema,
r#type::{OpenApiType, OpenApiVariants},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnumRepr {
U8,
U32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnumVariantTupleValue {
Ref(String),
}
impl EnumVariantTupleValue {
pub fn from_schema(schema: &OpenApiType) -> Option<Self> {
if let OpenApiType {
ref_path: Some(path),
..
} = schema
{
Some(Self::Ref((*path).to_owned()))
} else {
None
}
}
pub fn name(&self) -> Option<&str> {
let Self::Ref(path) = self;
path.strip_prefix("#/components/schemas/")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnumVariantValue {
Repr(u32),
String { rename: Option<String> },
Tuple(Vec<EnumVariantTupleValue>),
}
impl Default for EnumVariantValue {
fn default() -> Self {
Self::String { rename: None }
}
}
impl EnumVariantValue {
pub fn codegen_display(&self, name: &str) -> Option<TokenStream> {
match self {
Self::Repr(i) => Some(quote! { write!(f, "{}", #i) }),
Self::String { rename } => {
let name = rename.as_deref().unwrap_or(name);
Some(quote! { write!(f, #name) })
}
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EnumVariant {
pub name: String,
pub description: Option<String>,
pub value: EnumVariantValue,
}
impl EnumVariant {
pub fn codegen(&self) -> Option<TokenStream> {
let doc = self.description.as_ref().map(|d| {
quote! {
#[doc = #d]
}
});
let name = format_ident!("{}", self.name);
match &self.value {
EnumVariantValue::Repr(repr) => Some(quote! {
#doc
#name = #repr
}),
EnumVariantValue::String { rename } => {
let serde_attr = rename.as_ref().map(|r| {
quote! {
#[serde(rename = #r)]
}
});
Some(quote! {
#doc
#serde_attr
#name
})
}
EnumVariantValue::Tuple(values) => {
let mut val_tys = Vec::with_capacity(values.len());
for value in values {
let ty_name = value.name()?;
let ty_name = format_ident!("{ty_name}");
val_tys.push(quote! {
crate::models::#ty_name
});
}
Some(quote! {
#name(#(#val_tys),*)
})
}
}
}
pub fn codegen_display(&self) -> Option<TokenStream> {
let rhs = self.value.codegen_display(&self.name)?;
let name = format_ident!("{}", self.name);
Some(quote! {
Self::#name => #rhs
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Enum {
pub name: String,
pub description: Option<String>,
pub repr: Option<EnumRepr>,
pub copy: bool,
pub display: bool,
pub untagged: bool,
pub variants: Vec<EnumVariant>,
}
impl Enum {
pub fn from_schema(name: &str, schema: &OpenApiType) -> Option<Self> {
let mut result = Enum {
name: name.to_owned(),
description: schema.description.as_deref().map(ToOwned::to_owned),
copy: true,
..Default::default()
};
match &schema.r#enum {
Some(OpenApiVariants::Int(int_variants)) => {
result.repr = Some(EnumRepr::U32);
result.display = true;
result.variants = int_variants
.iter()
.copied()
.map(|i| EnumVariant {
name: format!("Variant{i}"),
value: EnumVariantValue::Repr(i as u32),
..Default::default()
})
.collect();
}
Some(OpenApiVariants::Str(str_variants)) => {
result.display = true;
result.variants = str_variants
.iter()
.copied()
.map(|s| {
let transformed = s.replace('&', "And").to_upper_camel_case();
EnumVariant {
value: EnumVariantValue::String {
rename: (transformed != s).then(|| s.to_owned()),
},
name: transformed,
..Default::default()
}
})
.collect();
}
None => return None,
}
Some(result)
}
pub fn from_parameter_schema(name: &str, schema: &OpenApiParameterSchema) -> Option<Self> {
let mut result = Self {
name: name.to_owned(),
copy: true,
display: true,
..Default::default()
};
for var in schema.r#enum.as_ref()? {
let transformed = var.to_upper_camel_case();
result.variants.push(EnumVariant {
value: EnumVariantValue::String {
rename: (transformed != *var).then(|| transformed.clone()),
},
name: transformed,
..Default::default()
});
}
Some(result)
}
pub fn from_one_of(name: &str, schemas: &[OpenApiType]) -> Option<Self> {
let mut result = Self {
name: name.to_owned(),
untagged: true,
..Default::default()
};
for schema in schemas {
let value = EnumVariantTupleValue::from_schema(schema)?;
let name = value.name()?.to_owned();
result.variants.push(EnumVariant {
name,
value: EnumVariantValue::Tuple(vec![value]),
..Default::default()
});
}
Some(result)
}
pub fn codegen(&self) -> Option<TokenStream> {
let repr = self.repr.map(|r| match r {
EnumRepr::U8 => quote! { #[repr(u8)]},
EnumRepr::U32 => quote! { #[repr(u32)]},
});
let name = format_ident!("{}", self.name);
let desc = self.description.as_ref().map(|d| {
quote! {
#repr
#[doc = #d]
}
});
let mut display = Vec::with_capacity(self.variants.len());
let mut variants = Vec::with_capacity(self.variants.len());
for variant in &self.variants {
variants.push(variant.codegen()?);
if self.display {
display.push(variant.codegen_display()?);
}
}
let mut derives = vec![];
if self.copy {
derives.extend_from_slice(&["Copy", "Hash"]);
}
let derives = derives.into_iter().map(|d| format_ident!("{d}"));
let serde_attr = self.untagged.then(|| {
quote! {
#[serde(untagged)]
}
});
let display = self.display.then(|| {
quote! {
impl std::fmt::Display for #name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
#(#display),*
}
}
}
}
});
Some(quote! {
#desc
#[derive(Debug, Clone, PartialEq, serde::Deserialize, #(#derives),*)]
#serde_attr
pub enum #name {
#(#variants),*
}
#display
})
}
}
#[cfg(test)]
mod test {
use crate::openapi::schema::OpenApiSchema;
use super::*;
#[test]
fn codegen() {
let schema = OpenApiSchema::read().unwrap();
let revive_setting = schema.components.schemas.get("ReviveSetting").unwrap();
let r#enum = Enum::from_schema("ReviveSetting", revive_setting).unwrap();
let code = r#enum.codegen().unwrap();
panic!("{code}");
}
}

View file

@ -0,0 +1,189 @@
use r#enum::Enum;
use indexmap::IndexMap;
use newtype::Newtype;
use object::Object;
use proc_macro2::TokenStream;
use crate::openapi::r#type::OpenApiType;
pub mod r#enum;
pub mod newtype;
pub mod object;
pub mod parameter;
pub mod path;
pub mod scope;
pub mod union;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Model {
Newtype(Newtype),
Enum(Enum),
Object(Object),
Unresolved,
}
pub fn resolve(r#type: &OpenApiType, name: &str, schemas: &IndexMap<&str, OpenApiType>) -> Model {
match r#type {
OpenApiType {
r#enum: Some(_), ..
} => Enum::from_schema(name, r#type).map_or(Model::Unresolved, Model::Enum),
OpenApiType {
r#type: Some("object"),
..
} => Object::from_schema_object(name, r#type, schemas)
.map_or(Model::Unresolved, Model::Object),
OpenApiType {
r#type: Some(_), ..
} => Newtype::from_schema(name, r#type).map_or(Model::Unresolved, Model::Newtype),
OpenApiType {
one_of: Some(types),
..
} => Enum::from_one_of(name, types).map_or(Model::Unresolved, Model::Enum),
OpenApiType {
all_of: Some(types),
..
} => Object::from_all_of(name, types, schemas).map_or(Model::Unresolved, Model::Object),
_ => Model::Unresolved,
}
}
impl Model {
pub fn codegen(&self) -> Option<TokenStream> {
match self {
Self::Newtype(newtype) => newtype.codegen(),
Self::Enum(r#enum) => r#enum.codegen(),
Self::Object(object) => object.codegen(),
Self::Unresolved => None,
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{
model::r#enum::{EnumRepr, EnumVariant},
openapi::schema::OpenApiSchema,
};
#[test]
fn resolve_newtypes() {
let schema = OpenApiSchema::read().unwrap();
let user_id_schema = schema.components.schemas.get("UserId").unwrap();
let user_id = resolve(user_id_schema, "UserId", &schema.components.schemas);
assert_eq!(
user_id,
Model::Newtype(Newtype {
name: "UserId".to_owned(),
description: None,
inner: newtype::NewtypeInner::I32,
copy: true,
ord: true
})
);
let attack_code_schema = schema.components.schemas.get("AttackCode").unwrap();
let attack_code = resolve(attack_code_schema, "AttackCode", &schema.components.schemas);
assert_eq!(
attack_code,
Model::Newtype(Newtype {
name: "AttackCode".to_owned(),
description: None,
inner: newtype::NewtypeInner::Str,
copy: false,
ord: false
})
);
}
#[test]
fn resolve_enums() {
let schema = OpenApiSchema::read().unwrap();
let forum_feed_type_schema = schema.components.schemas.get("ForumFeedTypeEnum").unwrap();
let forum_feed_type = resolve(
forum_feed_type_schema,
"ForumFeedTypeEnum",
&schema.components.schemas,
);
assert_eq!(forum_feed_type, Model::Enum(Enum {
name: "ForumFeedType".to_owned(),
description: Some("This represents the type of the activity. Values range from 1 to 8 where:\n * 1 = 'X posted on a thread',\n * 2 = 'X created a thread',\n * 3 = 'X liked your thread',\n * 4 = 'X disliked your thread',\n * 5 = 'X liked your post',\n * 6 = 'X disliked your post',\n * 7 = 'X quoted your post'.".to_owned()),
repr: Some(EnumRepr::U32),
copy: true,
untagged: false,
display: true,
variants: vec![
EnumVariant {
name: "Variant1".to_owned(),
value: r#enum::EnumVariantValue::Repr(1),
..Default::default()
},
EnumVariant {
name: "Variant2".to_owned(),
value: r#enum::EnumVariantValue::Repr(2),
..Default::default()
},
EnumVariant {
name: "Variant3".to_owned(),
value: r#enum::EnumVariantValue::Repr(3),
..Default::default()
},
EnumVariant {
name: "Variant4".to_owned(),
value: r#enum::EnumVariantValue::Repr(4),
..Default::default()
},
EnumVariant {
name: "Variant5".to_owned(),
value: r#enum::EnumVariantValue::Repr(5),
..Default::default()
},
EnumVariant {
name: "Variant6".to_owned(),
value: r#enum::EnumVariantValue::Repr(6),
..Default::default()
},
EnumVariant {
name: "Variant7".to_owned(),
value: r#enum::EnumVariantValue::Repr(7),
..Default::default()
},
]
}))
}
#[test]
fn resolve_all() {
let schema = OpenApiSchema::read().unwrap();
let mut unresolved = vec![];
let total = schema.components.schemas.len();
for (name, desc) in &schema.components.schemas {
if resolve(desc, name, &schema.components.schemas) == Model::Unresolved {
unresolved.push(name);
}
}
if !unresolved.is_empty() {
panic!(
"Failed to resolve {}/{} types. Could not resolve [{}]",
unresolved.len(),
total,
unresolved
.into_iter()
.map(|u| format!("`{u}`"))
.collect::<Vec<_>>()
.join(", ")
)
}
}
}

View file

@ -0,0 +1,144 @@
use quote::{format_ident, quote};
use crate::openapi::r#type::OpenApiType;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NewtypeInner {
Str,
I32,
I64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Newtype {
pub name: String,
pub description: Option<String>,
pub inner: NewtypeInner,
pub copy: bool,
pub ord: bool,
}
impl Newtype {
pub fn from_schema(name: &str, schema: &OpenApiType) -> Option<Self> {
let name = name.to_owned();
let description = schema.description.as_deref().map(ToOwned::to_owned);
match schema {
OpenApiType {
r#type: Some("string"),
..
} => Some(Self {
name,
description,
inner: NewtypeInner::Str,
copy: false,
ord: false,
}),
OpenApiType {
r#type: Some("integer"),
format: Some("int32"),
..
} => Some(Self {
name,
description,
inner: NewtypeInner::I32,
copy: true,
ord: true,
}),
OpenApiType {
r#type: Some("integer"),
format: Some("int64"),
..
} => Some(Self {
name,
description,
inner: NewtypeInner::I64,
copy: true,
ord: true,
}),
_ => None,
}
}
pub fn codegen(&self) -> Option<proc_macro2::TokenStream> {
let mut derives = vec![quote! { Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize }];
if self.copy {
derives.push(quote! { Copy });
}
if self.ord {
derives.push(quote! { PartialOrd, Ord });
}
let name = format_ident!("{}", self.name);
let inner = match self.inner {
NewtypeInner::Str => format_ident!("String"),
NewtypeInner::I32 => format_ident!("i32"),
NewtypeInner::I64 => format_ident!("i64"),
};
let doc = self.description.as_ref().map(|d| {
quote! {
#[doc = #d]
}
});
let body = quote! {
#doc
#[derive(#(#derives),*)]
pub struct #name(pub #inner);
impl #name {
pub fn new(inner: #inner) -> Self {
Self(inner)
}
pub fn into_inner(self) -> #inner {
self.0
}
}
impl From<#inner> for #name {
fn from(inner: #inner) -> Self {
Self(inner)
}
}
impl From<#name> for #inner {
fn from(outer: #name) -> Self {
outer.0
}
}
impl std::fmt::Display for #name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
};
Some(body)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::openapi::schema::OpenApiSchema;
#[test]
fn codegen() {
let schema = OpenApiSchema::read().unwrap();
let user_id = schema.components.schemas.get("UserId").unwrap();
let mut newtype = Newtype::from_schema("UserId", user_id).unwrap();
newtype.description = Some("Description goes here".to_owned());
let code = newtype.codegen().unwrap().to_string();
panic!("{code}");
}
}

View file

@ -0,0 +1,448 @@
use heck::{ToSnakeCase, ToUpperCamelCase};
use indexmap::IndexMap;
use proc_macro2::TokenStream;
use quote::{ToTokens, format_ident, quote};
use syn::Ident;
use crate::openapi::r#type::OpenApiType;
use super::r#enum::Enum;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrimitiveType {
Bool,
I32,
I64,
String,
Float,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PropertyType {
Primitive(PrimitiveType),
Ref(String),
Enum(Enum),
Nested(Box<Object>),
Array(Box<PropertyType>),
}
impl PropertyType {
pub fn codegen(&self, namespace: &mut ObjectNamespace) -> Option<TokenStream> {
match self {
Self::Primitive(PrimitiveType::Bool) => Some(format_ident!("bool").into_token_stream()),
Self::Primitive(PrimitiveType::I32) => Some(format_ident!("i32").into_token_stream()),
Self::Primitive(PrimitiveType::I64) => Some(format_ident!("i64").into_token_stream()),
Self::Primitive(PrimitiveType::String) => {
Some(format_ident!("String").into_token_stream())
}
Self::Primitive(PrimitiveType::Float) => Some(format_ident!("f64").into_token_stream()),
Self::Ref(path) => {
let name = path.strip_prefix("#/components/schemas/")?;
let name = format_ident!("{name}");
Some(quote! { crate::models::#name })
}
Self::Enum(r#enum) => {
let code = r#enum.codegen()?;
namespace.push_element(code);
let ns = namespace.get_ident();
let name = format_ident!("{}", r#enum.name);
Some(quote! {
#ns::#name
})
}
Self::Array(array) => {
let inner_ty = array.codegen(namespace)?;
Some(quote! {
Vec<#inner_ty>
})
}
Self::Nested(nested) => {
let code = nested.codegen()?;
namespace.push_element(code);
let ns = namespace.get_ident();
let name = format_ident!("{}", nested.name);
Some(quote! {
#ns::#name
})
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Property {
pub name: String,
pub description: Option<String>,
pub required: bool,
pub nullable: bool,
pub r#type: PropertyType,
}
impl Property {
pub fn from_schema(
name: &str,
required: bool,
schema: &OpenApiType,
schemas: &IndexMap<&str, OpenApiType>,
) -> Option<Self> {
let name = name.to_owned();
let description = schema.description.as_deref().map(ToOwned::to_owned);
match schema {
OpenApiType {
r#enum: Some(_), ..
} => Some(Self {
r#type: PropertyType::Enum(Enum::from_schema(
&name.clone().to_upper_camel_case(),
schema,
)?),
name,
description,
required,
nullable: false,
}),
OpenApiType {
one_of: Some(types),
..
} => match types.as_slice() {
[
left,
OpenApiType {
r#type: Some("null"),
..
},
] => {
let mut inner = Self::from_schema(&name, required, left, schemas)?;
inner.nullable = true;
Some(inner)
}
[
left @ ..,
OpenApiType {
r#type: Some("null"),
..
},
] => {
let rest = OpenApiType {
one_of: Some(left.to_owned()),
..schema.clone()
};
let mut inner = Self::from_schema(&name, required, &rest, schemas)?;
inner.nullable = true;
Some(inner)
}
cases => {
let r#enum = Enum::from_one_of(&name.to_upper_camel_case(), cases)?;
Some(Self {
name,
description: None,
required,
nullable: false,
r#type: PropertyType::Enum(r#enum),
})
}
},
OpenApiType {
all_of: Some(types),
..
} => {
let composite = Object::from_all_of(&name.to_upper_camel_case(), types, schemas)?;
Some(Self {
name,
description: None,
required,
nullable: false,
r#type: PropertyType::Nested(Box::new(composite)),
})
}
OpenApiType {
r#type: Some("object"),
..
} => Some(Self {
r#type: PropertyType::Nested(Box::new(Object::from_schema_object(
&name.clone().to_upper_camel_case(),
schema,
schemas,
)?)),
name,
description,
required,
nullable: false,
}),
OpenApiType {
ref_path: Some(path),
..
} => Some(Self {
name,
description,
r#type: PropertyType::Ref((*path).to_owned()),
required,
nullable: false,
}),
OpenApiType {
r#type: Some("array"),
items: Some(items),
..
} => {
let inner = Self::from_schema(&name, required, items, schemas)?;
Some(Self {
name,
description,
required,
nullable: false,
r#type: PropertyType::Array(Box::new(inner.r#type)),
})
}
OpenApiType {
r#type: Some(_), ..
} => {
let prim = match (schema.r#type, schema.format) {
(Some("integer"), Some("int32")) => PrimitiveType::I32,
(Some("integer"), Some("int64")) => PrimitiveType::I64,
(Some("number"), Some("float")) => PrimitiveType::Float,
(Some("string"), None) => PrimitiveType::String,
(Some("boolean"), None) => PrimitiveType::Bool,
_ => return None,
};
Some(Self {
name,
description,
required,
nullable: false,
r#type: PropertyType::Primitive(prim),
})
}
_ => None,
}
}
pub fn codegen(&self, namespace: &mut ObjectNamespace) -> Option<TokenStream> {
let desc = self.description.as_ref().map(|d| quote! { #[doc = #d]});
let name = &self.name;
let (name, serde_attr) = match name.as_str() {
"type" => (format_ident!("r#type"), None),
name if name != name.to_snake_case() => (
format_ident!("{}", name.to_snake_case()),
Some(quote! { #[serde(rename = #name)]}),
),
_ => (format_ident!("{name}"), None),
};
let ty_inner = self.r#type.codegen(namespace)?;
let ty = if !self.required || self.nullable {
quote! { Option<#ty_inner> }
} else {
ty_inner
};
Some(quote! {
#desc
#serde_attr
pub #name: #ty
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Object {
pub name: String,
pub description: Option<String>,
pub properties: Vec<Property>,
}
impl Object {
pub fn from_schema_object(
name: &str,
schema: &OpenApiType,
schemas: &IndexMap<&str, OpenApiType>,
) -> Option<Self> {
let mut result = Object {
name: name.to_owned(),
description: schema.description.as_deref().map(ToOwned::to_owned),
..Default::default()
};
let Some(props) = &schema.properties else {
return None;
};
let required = schema.required.clone().unwrap_or_default();
for (prop_name, prop) in props {
// HACK: This will cause a duplicate key otherwise
if *prop_name == "itemDetails" {
continue;
}
// TODO: implement custom enum for this (depends on overrides being added)
if *prop_name == "value" && name == "TornHof" {
continue;
}
result.properties.push(Property::from_schema(
prop_name,
required.contains(prop_name),
prop,
schemas,
)?);
}
Some(result)
}
pub fn from_all_of(
name: &str,
types: &[OpenApiType],
schemas: &IndexMap<&str, OpenApiType>,
) -> Option<Self> {
let mut result = Self {
name: name.to_owned(),
..Default::default()
};
for r#type in types {
let r#type = if let OpenApiType {
ref_path: Some(path),
..
} = r#type
{
let name = path.strip_prefix("#/components/schemas/")?;
schemas.get(name)?
} else {
r#type
};
let obj = Self::from_schema_object(name, r#type, schemas)?;
result.description = result.description.or(obj.description);
result.properties.extend(obj.properties);
}
Some(result)
}
pub fn codegen(&self) -> Option<TokenStream> {
let doc = self.description.as_ref().map(|d| {
quote! {
#[doc = #d]
}
});
let mut namespace = ObjectNamespace {
object: self,
ident: None,
elements: Vec::default(),
};
let mut props = Vec::with_capacity(self.properties.len());
for prop in &self.properties {
props.push(prop.codegen(&mut namespace)?);
}
let name = format_ident!("{}", self.name);
let ns = namespace.codegen();
Some(quote! {
#ns
#doc
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
pub struct #name {
#(#props),*
}
})
}
}
pub struct ObjectNamespace<'o> {
object: &'o Object,
ident: Option<Ident>,
elements: Vec<TokenStream>,
}
impl ObjectNamespace<'_> {
pub fn get_ident(&mut self) -> Ident {
self.ident
.get_or_insert_with(|| {
let name = self.object.name.to_snake_case();
format_ident!("{name}")
})
.clone()
}
pub fn push_element(&mut self, el: TokenStream) {
self.elements.push(el);
}
pub fn codegen(mut self) -> Option<TokenStream> {
if self.elements.is_empty() {
None
} else {
let ident = self.get_ident();
let elements = self.elements;
Some(quote! {
pub mod #ident {
#(#elements)*
}
})
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::openapi::schema::OpenApiSchema;
#[test]
fn resolve_object() {
let schema = OpenApiSchema::read().unwrap();
let attack = schema.components.schemas.get("FactionUpgrades").unwrap();
let resolved =
Object::from_schema_object("FactionUpgrades", attack, &schema.components.schemas)
.unwrap();
let _code = resolved.codegen().unwrap();
}
#[test]
fn resolve_objects() {
let schema = OpenApiSchema::read().unwrap();
let mut objects = 0;
let mut unresolved = vec![];
for (name, desc) in &schema.components.schemas {
if desc.r#type == Some("object") {
objects += 1;
if Object::from_schema_object(name, desc, &schema.components.schemas).is_none() {
unresolved.push(name);
}
}
}
if !unresolved.is_empty() {
panic!(
"Failed to resolve {}/{} objects. Could not resolve [{}]",
unresolved.len(),
objects,
unresolved
.into_iter()
.map(|u| format!("`{u}`"))
.collect::<Vec<_>>()
.join(", ")
)
}
}
}

View file

@ -0,0 +1,431 @@
use std::fmt::Write;
use heck::ToUpperCamelCase;
use proc_macro2::TokenStream;
use quote::{ToTokens, format_ident, quote};
use crate::openapi::parameter::{
OpenApiParameter, OpenApiParameterDefault, OpenApiParameterSchema,
ParameterLocation as SchemaLocation,
};
use super::r#enum::Enum;
#[derive(Debug, Clone)]
pub struct ParameterOptions<P> {
pub default: Option<P>,
pub minimum: Option<P>,
pub maximum: Option<P>,
}
#[derive(Debug, Clone)]
pub enum ParameterType {
I32 {
options: ParameterOptions<i32>,
},
String,
Boolean,
Enum {
options: ParameterOptions<String>,
r#type: Enum,
},
Schema {
type_name: String,
},
Array {
items: Box<ParameterType>,
},
}
impl ParameterType {
pub fn from_schema(name: &str, schema: &OpenApiParameterSchema) -> Option<Self> {
match schema {
OpenApiParameterSchema {
r#type: Some("integer"),
// BUG: missing for some types in the spec
// format: Some("int32"),
..
} => {
let default = match schema.default {
Some(OpenApiParameterDefault::Int(d)) => Some(d),
None => None,
_ => return None,
};
Some(Self::I32 {
options: ParameterOptions {
default,
minimum: schema.minimum,
maximum: schema.maximum,
},
})
}
OpenApiParameterSchema {
r#type: Some("string"),
r#enum: Some(variants),
..
} if variants.as_slice() == ["true", "false"]
|| variants.as_slice() == ["false", "true "] =>
{
Some(ParameterType::Boolean)
}
OpenApiParameterSchema {
r#type: Some("string"),
r#enum: Some(_),
..
} => {
let default = match schema.default {
Some(OpenApiParameterDefault::Str(d)) => Some(d.to_owned()),
None => None,
_ => return None,
};
Some(ParameterType::Enum {
options: ParameterOptions {
default,
minimum: None,
maximum: None,
},
r#type: Enum::from_parameter_schema(name, schema)?,
})
}
OpenApiParameterSchema {
r#type: Some("string"),
..
} => Some(ParameterType::String),
OpenApiParameterSchema {
ref_path: Some(path),
..
} => {
let type_name = path.strip_prefix("#/components/schemas/")?.to_owned();
Some(ParameterType::Schema { type_name })
}
OpenApiParameterSchema {
r#type: Some("array"),
items: Some(items),
..
} => Some(Self::Array {
items: Box::new(Self::from_schema(name, items)?),
}),
_ => None,
}
}
pub fn codegen_type_name(&self, name: &str) -> TokenStream {
match self {
Self::I32 { .. } | Self::String | Self::Enum { .. } | Self::Array { .. } => {
format_ident!("{name}").into_token_stream()
}
Self::Boolean => quote! { bool },
Self::Schema { type_name } => {
let type_name = format_ident!("{type_name}",);
quote! { crate::models::#type_name }
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParameterLocation {
Query,
Path,
}
#[derive(Debug, Clone)]
pub struct Parameter {
pub name: String,
pub value: String,
pub description: Option<String>,
pub r#type: ParameterType,
pub required: bool,
pub location: ParameterLocation,
}
impl Parameter {
pub fn from_schema(name: &str, schema: &OpenApiParameter) -> Option<Self> {
let name = match name {
"From" => "FromTimestamp".to_owned(),
"To" => "ToTimestamp".to_owned(),
name => name.to_owned(),
};
let value = schema.name.to_owned();
let description = schema.description.as_deref().map(ToOwned::to_owned);
let location = match &schema.r#in {
SchemaLocation::Query => ParameterLocation::Query,
SchemaLocation::Path => ParameterLocation::Path,
};
let r#type = ParameterType::from_schema(&name, &schema.schema)?;
Some(Self {
name,
value,
description,
r#type,
required: schema.required,
location,
})
}
pub fn codegen(&self) -> Option<TokenStream> {
match &self.r#type {
ParameterType::I32 { options } => {
let name = format_ident!("{}", self.name);
let mut desc = self.description.as_deref().unwrap_or_default().to_owned();
if options.default.is_some()
|| options.minimum.is_some()
|| options.maximum.is_some()
{
_ = writeln!(desc, "\n # Notes");
}
let constructor = if let (Some(min), Some(max)) = (options.minimum, options.maximum)
{
_ = write!(desc, "Values have to lie between {min} and {max}. ");
let name_raw = &self.name;
quote! {
impl #name {
pub fn new(inner: i32) -> Result<Self, crate::ParameterError> {
if inner > #max || inner < #min {
Err(crate::ParameterError::OutOfRange { value: inner, name: #name_raw })
} else {
Ok(Self(inner))
}
}
}
impl TryFrom<i32> for #name {
type Error = crate::ParameterError;
fn try_from(inner: i32) -> Result<Self, Self::Error> {
if inner > #max || inner < #min {
Err(crate::ParameterError::OutOfRange { value: inner, name: #name_raw })
} else {
Ok(Self(inner))
}
}
}
}
} else {
quote! {
impl #name {
pub fn new(inner: i32) -> Self {
Self(inner)
}
}
}
};
if let Some(default) = options.default {
_ = write!(desc, "The default value is {default}.");
}
let doc = quote! {
#[doc = #desc]
};
Some(quote! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#doc
pub struct #name(i32);
#constructor
impl From<#name> for i32 {
fn from(value: #name) -> Self {
value.0
}
}
impl #name {
pub fn into_inner(self) -> i32 {
self.0
}
}
impl std::fmt::Display for #name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
})
}
ParameterType::Enum { options, r#type } => {
let mut desc = self.description.as_deref().unwrap_or_default().to_owned();
if let Some(default) = &options.default {
let default = default.to_upper_camel_case();
_ = write!(
desc,
r#"
# Notes
The default value [Self::{}](self::{}#variant.{})"#,
default, self.name, default
);
}
let doc = quote! { #[doc = #desc]};
let inner = r#type.codegen()?;
Some(quote! {
#doc
#inner
})
}
ParameterType::Array { items } => {
let (inner_name, outer_name) = match items.as_ref() {
ParameterType::I32 { .. }
| ParameterType::String
| ParameterType::Array { .. }
| ParameterType::Enum { .. } => self.name.strip_suffix('s').map_or_else(
|| (self.name.to_owned(), format!("{}s", self.name)),
|s| (s.to_owned(), self.name.to_owned()),
),
ParameterType::Boolean => ("bool".to_owned(), self.name.clone()),
ParameterType::Schema { type_name } => (type_name.clone(), self.name.clone()),
};
let inner = Self {
r#type: *items.clone(),
name: inner_name.clone(),
..self.clone()
};
let mut code = inner.codegen().unwrap_or_default();
let name = format_ident!("{}", outer_name);
let inner_ty = items.codegen_type_name(&inner_name);
code.extend(quote! {
#[derive(Debug, Clone)]
pub struct #name(pub Vec<#inner_ty>);
impl std::fmt::Display for #name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let mut first = true;
for el in &self.0 {
if first {
first = false;
write!(f, "{el}")?;
} else {
write!(f, ",{el}")?;
}
}
Ok(())
}
}
});
Some(code)
}
_ => None,
}
}
}
#[cfg(test)]
mod test {
use crate::openapi::{path::OpenApiPathParameter, schema::OpenApiSchema};
use super::*;
#[test]
fn resolve_components() {
let schema = OpenApiSchema::read().unwrap();
let mut parameters = 0;
let mut unresolved = vec![];
for (name, desc) in &schema.components.parameters {
parameters += 1;
if Parameter::from_schema(name, desc).is_none() {
unresolved.push(name);
}
}
if !unresolved.is_empty() {
panic!(
"Failed to resolve {}/{} params. Could not resolve [{}]",
unresolved.len(),
parameters,
unresolved
.into_iter()
.map(|u| format!("`{u}`"))
.collect::<Vec<_>>()
.join(", ")
)
}
}
#[test]
fn resolve_inline() {
let schema = OpenApiSchema::read().unwrap();
let mut params = 0;
let mut unresolved = Vec::new();
for (path, body) in &schema.paths {
for param in &body.get.parameters {
if let OpenApiPathParameter::Inline(inline) = param {
params += 1;
if Parameter::from_schema(inline.name, inline).is_none() {
unresolved.push(format!("`{}.{}`", path, inline.name));
}
}
}
}
if !unresolved.is_empty() {
panic!(
"Failed to resolve {}/{} inline params. Could not resolve [{}]",
unresolved.len(),
params,
unresolved.join(", ")
)
}
}
#[test]
fn codegen_inline() {
let schema = OpenApiSchema::read().unwrap();
let mut params = 0;
let mut unresolved = Vec::new();
for (path, body) in &schema.paths {
for param in &body.get.parameters {
if let OpenApiPathParameter::Inline(inline) = param {
if inline.r#in == SchemaLocation::Query {
let Some(param) = Parameter::from_schema(inline.name, inline) else {
continue;
};
if matches!(
param.r#type,
ParameterType::Schema { .. }
| ParameterType::Boolean
| ParameterType::String
) {
continue;
}
params += 1;
if param.codegen().is_none() {
unresolved.push(format!("`{}.{}`", path, inline.name));
}
}
}
}
}
if !unresolved.is_empty() {
panic!(
"Failed to codegen {}/{} inline params. Could not codegen [{}]",
unresolved.len(),
params,
unresolved.join(", ")
)
}
}
}

View file

@ -0,0 +1,482 @@
use std::{fmt::Write, ops::Deref};
use heck::{ToSnakeCase, ToUpperCamelCase};
use indexmap::IndexMap;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::Ident;
use crate::openapi::{
parameter::OpenApiParameter,
path::{OpenApiPath, OpenApiPathParameter, OpenApiResponseBody},
};
use super::{
parameter::{Parameter, ParameterLocation, ParameterType},
union::Union,
};
#[derive(Debug, Clone)]
pub enum PathSegment {
Constant(String),
Parameter { name: String },
}
#[derive(Debug, Clone)]
pub enum PathParameter {
Inline(Parameter),
Component(Parameter),
}
#[derive(Debug, Clone)]
pub enum PathResponse {
Component { name: String },
// TODO: needs to be implemented
ArbitraryUnion(Union),
}
#[derive(Debug, Clone)]
pub struct Path {
pub segments: Vec<PathSegment>,
pub name: String,
pub summary: Option<String>,
pub description: String,
pub parameters: Vec<PathParameter>,
pub response: PathResponse,
}
impl Path {
pub fn from_schema(
path: &str,
schema: &OpenApiPath,
parameters: &IndexMap<&str, OpenApiParameter>,
) -> Option<Self> {
let mut segments = Vec::new();
for segment in path.strip_prefix('/')?.split('/') {
if segment.starts_with('{') && segment.ends_with('}') {
segments.push(PathSegment::Parameter {
name: segment[1..(segment.len() - 1)].to_owned(),
});
} else {
segments.push(PathSegment::Constant(segment.to_owned()));
}
}
let summary = schema.get.summary.as_deref().map(ToOwned::to_owned);
let description = schema.get.description.deref().to_owned();
let mut params = Vec::with_capacity(schema.get.parameters.len());
for parameter in &schema.get.parameters {
match &parameter {
OpenApiPathParameter::Link { ref_path } => {
let name = ref_path
.strip_prefix("#/components/parameters/")?
.to_owned();
let param = parameters.get(&name.as_str())?;
params.push(PathParameter::Component(Parameter::from_schema(
&name, param,
)?));
}
OpenApiPathParameter::Inline(schema) => {
let name = schema.name.to_upper_camel_case();
let parameter = Parameter::from_schema(&name, schema)?;
params.push(PathParameter::Inline(parameter));
}
};
}
let mut suffixes = vec![];
let mut name = String::new();
for seg in &segments {
match seg {
PathSegment::Constant(val) => {
name.push_str(&val.to_upper_camel_case());
}
PathSegment::Parameter { name } => {
suffixes.push(format!("For{}", name.to_upper_camel_case()));
}
}
}
for suffix in suffixes {
name.push_str(&suffix);
}
let response = match &schema.get.response_content {
OpenApiResponseBody::Schema(link) => PathResponse::Component {
name: link
.ref_path
.strip_prefix("#/components/schemas/")?
.to_owned(),
},
OpenApiResponseBody::Union { any_of: _ } => PathResponse::ArbitraryUnion(
Union::from_schema("Response", &schema.get.response_content)?,
),
};
Some(Self {
segments,
name,
summary,
description,
parameters: params,
response,
})
}
pub fn codegen_request(&self) -> Option<TokenStream> {
let name = if self.segments.len() == 1 {
let Some(PathSegment::Constant(first)) = self.segments.first() else {
return None;
};
format_ident!("{}Request", first.to_upper_camel_case())
} else {
format_ident!("{}Request", self.name)
};
let mut ns = PathNamespace {
path: self,
ident: None,
elements: Vec::new(),
};
let mut fields = Vec::with_capacity(self.parameters.len());
let mut convert_field = Vec::with_capacity(self.parameters.len());
let mut start_fields = Vec::new();
let mut discriminant = Vec::new();
let mut discriminant_val = Vec::new();
let mut fmt_val = Vec::new();
for param in &self.parameters {
let (is_inline, param) = match &param {
PathParameter::Inline(param) => (true, param),
PathParameter::Component(param) => (false, param),
};
let ty = match &param.r#type {
ParameterType::I32 { .. } | ParameterType::Enum { .. } => {
let ty_name = format_ident!("{}", param.name);
if is_inline {
ns.push_element(param.codegen()?);
let path = ns.get_ident();
quote! {
crate::request::models::#path::#ty_name
}
} else {
quote! {
crate::parameters::#ty_name
}
}
}
ParameterType::String => quote! { String },
ParameterType::Boolean => quote! { bool },
ParameterType::Schema { type_name } => {
let ty_name = format_ident!("{}", type_name);
quote! {
crate::models::#ty_name
}
}
ParameterType::Array { .. } => {
ns.push_element(param.codegen()?);
let ty_name = param.r#type.codegen_type_name(&param.name);
let path = ns.get_ident();
quote! {
crate::request::models::#path::#ty_name
}
}
};
let name = format_ident!("{}", param.name.to_snake_case());
let query_val = &param.value;
if param.location == ParameterLocation::Path {
discriminant.push(ty.clone());
discriminant_val.push(quote! { self.#name });
let path_name = format_ident!("{}", param.value);
start_fields.push(quote! {
#[builder(start_fn)]
pub #name: #ty
});
fmt_val.push(quote! {
#path_name=self.#name
});
} else {
let ty = if param.required {
convert_field.push(quote! {
.chain(std::iter::once(&self.#name).map(|v| (#query_val, v.to_string())))
});
ty
} else {
convert_field.push(quote! {
.chain(self.#name.as_ref().into_iter().map(|v| (#query_val, v.to_string())))
});
quote! { Option<#ty>}
};
fields.push(quote! {
pub #name: #ty
});
}
}
let response_ty = match &self.response {
PathResponse::Component { name } => {
let name = format_ident!("{name}");
quote! {
crate::models::#name
}
}
PathResponse::ArbitraryUnion(union) => {
let path = ns.get_ident();
let ty_name = format_ident!("{}", union.name);
quote! {
crate::request::models::#path::#ty_name
}
}
};
let mut path_fmt_str = String::new();
for seg in &self.segments {
match seg {
PathSegment::Constant(val) => _ = write!(path_fmt_str, "/{}", val),
PathSegment::Parameter { name } => _ = write!(path_fmt_str, "/{{{}}}", name),
}
}
if let PathResponse::ArbitraryUnion(union) = &self.response {
ns.push_element(union.codegen()?);
}
let ns = ns.codegen();
start_fields.extend(fields);
Some(quote! {
#ns
#[derive(Debug, Clone, bon::Builder)]
#[builder(state_mod(vis = "pub(crate)"))]
pub struct #name {
#(#start_fields),*
}
impl crate::request::IntoRequest for #name {
#[allow(unused_parens)]
type Discriminant = (#(#discriminant),*);
type Response = #response_ty;
fn into_request(self) -> crate::request::ApiRequest<Self::Discriminant> {
#[allow(unused_parens)]
crate::request::ApiRequest {
path: format!(#path_fmt_str, #(#fmt_val),*),
parameters: std::iter::empty()
#(#convert_field)*
.collect(),
disriminant: (#(#discriminant_val),*),
}
}
}
})
}
pub fn codegen_scope_call(&self) -> Option<TokenStream> {
let mut extra_args = Vec::new();
let mut disc = Vec::new();
let snake_name = self.name.to_snake_case();
let request_name = format_ident!("{}Request", self.name);
let builder_name = format_ident!("{}RequestBuilder", self.name);
let builder_mod_name = format_ident!("{}_request_builder", snake_name);
let request_mod_name = format_ident!("{snake_name}");
let request_path = quote! { crate::request::models::#request_name };
let builder_path = quote! { crate::request::models::#builder_name };
let builder_mod_path = quote! { crate::request::models::#builder_mod_name };
let tail = snake_name
.split_once('_')
.map_or_else(|| "for_selections".to_owned(), |(_, tail)| tail.to_owned());
let fn_name = format_ident!("{tail}");
for param in &self.parameters {
let (param, is_inline) = match param {
PathParameter::Inline(param) => (param, true),
PathParameter::Component(param) => (param, false),
};
if param.location == ParameterLocation::Path {
let ty = match &param.r#type {
ParameterType::I32 { .. } | ParameterType::Enum { .. } => {
let ty_name = format_ident!("{}", param.name);
if is_inline {
quote! {
crate::request::models::#request_mod_name::#ty_name
}
} else {
quote! {
crate::parameters::#ty_name
}
}
}
ParameterType::String => quote! { String },
ParameterType::Boolean => quote! { bool },
ParameterType::Schema { type_name } => {
let ty_name = format_ident!("{}", type_name);
quote! {
crate::models::#ty_name
}
}
ParameterType::Array { .. } => param.r#type.codegen_type_name(&param.name),
};
let arg_name = format_ident!("{}", param.value.to_snake_case());
extra_args.push(quote! { #arg_name: #ty, });
disc.push(arg_name);
}
}
let response_ty = match &self.response {
PathResponse::Component { name } => {
let name = format_ident!("{name}");
quote! {
crate::models::#name
}
}
PathResponse::ArbitraryUnion(union) => {
let name = format_ident!("{}", union.name);
quote! {
crate::request::models::#request_mod_name::#name
}
}
};
Some(quote! {
pub async fn #fn_name<S>(
&self,
#(#extra_args)*
builder: impl FnOnce(
#builder_path<#builder_mod_path::Empty>
) -> #builder_path<S>,
) -> Result<#response_ty, E::Error>
where
S: #builder_mod_path::IsComplete,
{
let r = builder(#request_path::builder(#(#disc),*)).build();
self.0.fetch(r).await
}
})
}
}
pub struct PathNamespace<'r> {
path: &'r Path,
ident: Option<Ident>,
elements: Vec<TokenStream>,
}
impl PathNamespace<'_> {
pub fn get_ident(&mut self) -> Ident {
self.ident
.get_or_insert_with(|| {
let name = self.path.name.to_snake_case();
format_ident!("{name}")
})
.clone()
}
pub fn push_element(&mut self, el: TokenStream) {
self.elements.push(el);
}
pub fn codegen(mut self) -> Option<TokenStream> {
if self.elements.is_empty() {
None
} else {
let ident = self.get_ident();
let elements = self.elements;
Some(quote! {
pub mod #ident {
#(#elements)*
}
})
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::openapi::schema::OpenApiSchema;
#[test]
fn resolve_paths() {
let schema = OpenApiSchema::read().unwrap();
let mut paths = 0;
let mut unresolved = vec![];
for (name, desc) in &schema.paths {
paths += 1;
if Path::from_schema(name, desc, &schema.components.parameters).is_none() {
unresolved.push(name);
}
}
if !unresolved.is_empty() {
panic!(
"Failed to resolve {}/{} paths. Could not resolve [{}]",
unresolved.len(),
paths,
unresolved
.into_iter()
.map(|u| format!("`{u}`"))
.collect::<Vec<_>>()
.join(", ")
)
}
}
#[test]
fn codegen_paths() {
let schema = OpenApiSchema::read().unwrap();
let mut paths = 0;
let mut unresolved = vec![];
for (name, desc) in &schema.paths {
paths += 1;
let Some(path) = Path::from_schema(name, desc, &schema.components.parameters) else {
unresolved.push(name);
continue;
};
if path.codegen_scope_call().is_none() || path.codegen_request().is_none() {
unresolved.push(name);
}
}
if !unresolved.is_empty() {
panic!(
"Failed to codegen {}/{} paths. Could not resolve [{}]",
unresolved.len(),
paths,
unresolved
.into_iter()
.map(|u| format!("`{u}`"))
.collect::<Vec<_>>()
.join(", ")
)
}
}
}

View file

@ -0,0 +1,64 @@
use heck::ToUpperCamelCase;
use indexmap::IndexMap;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use super::path::{Path, PathSegment};
pub struct Scope {
pub name: String,
pub mod_name: String,
pub members: Vec<Path>,
}
impl Scope {
pub fn from_paths(paths: Vec<Path>) -> Vec<Scope> {
let mut map = IndexMap::new();
for path in paths {
let Some(PathSegment::Constant(first_seg)) = path.segments.first() else {
continue;
};
map.entry(first_seg.to_owned())
.or_insert_with(|| Scope {
name: format!("{}Scope", first_seg.to_upper_camel_case()),
mod_name: first_seg.clone(),
members: Vec::new(),
})
.members
.push(path);
}
map.into_values().collect()
}
pub fn codegen(&self) -> Option<TokenStream> {
let name = format_ident!("{}", self.name);
let mut functions = Vec::with_capacity(self.members.len());
for member in &self.members {
if let Some(code) = member.codegen_scope_call() {
functions.push(code);
}
}
Some(quote! {
pub struct #name<'e, E>(&'e E)
where
E: crate::executor::Executor;
impl<'e, E> #name<'e, E>
where
E: crate::executor::Executor
{
pub fn new(executor: &'e E) -> Self {
Self(executor)
}
#(#functions)*
}
})
}
}

View file

@ -0,0 +1,50 @@
use heck::ToSnakeCase;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use crate::openapi::path::OpenApiResponseBody;
#[derive(Debug, Clone)]
pub struct Union {
pub name: String,
pub members: Vec<String>,
}
impl Union {
pub fn from_schema(name: &str, schema: &OpenApiResponseBody) -> Option<Self> {
let members = match schema {
OpenApiResponseBody::Union { any_of } => {
any_of.iter().map(|l| l.ref_path.to_owned()).collect()
}
_ => return None,
};
let name = name.to_owned();
Some(Self { name, members })
}
pub fn codegen(&self) -> Option<TokenStream> {
let name = format_ident!("{}", self.name);
let mut variants = Vec::new();
for member in &self.members {
let variant_name = member.strip_prefix("#/components/schemas/")?;
let accessor_name = format_ident!("{}", variant_name.to_snake_case());
let ty_name = format_ident!("{}", variant_name);
variants.push(quote! {
pub fn #accessor_name(&self) -> Result<crate::models::#ty_name, serde_json::Error> {
<crate::models::#ty_name as serde::Deserialize>::deserialize(&self.0)
}
});
}
Some(quote! {
#[derive(Debug, Clone, serde::Deserialize)]
pub struct #name(serde_json::Value);
impl #name {
#(#variants)*
}
})
}
}

View file

@ -0,0 +1,4 @@
pub mod parameter;
pub mod path;
pub mod schema;
pub mod r#type;

View file

@ -0,0 +1,40 @@
use std::borrow::Cow;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParameterLocation {
Query,
Path,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum OpenApiParameterDefault<'a> {
Int(i32),
Str(&'a str),
}
#[derive(Debug, Clone, Deserialize)]
pub struct OpenApiParameterSchema<'a> {
#[serde(rename = "$ref")]
pub ref_path: Option<&'a str>,
pub r#type: Option<&'a str>,
pub r#enum: Option<Vec<&'a str>>,
pub format: Option<&'a str>,
pub default: Option<OpenApiParameterDefault<'a>>,
pub maximum: Option<i32>,
pub minimum: Option<i32>,
pub items: Option<Box<OpenApiParameterSchema<'a>>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OpenApiParameter<'a> {
pub name: &'a str,
pub description: Option<Cow<'a, str>>,
pub r#in: ParameterLocation,
pub required: bool,
#[serde(borrow)]
pub schema: OpenApiParameterSchema<'a>,
}

View file

@ -0,0 +1,81 @@
use std::borrow::Cow;
use serde::{Deserialize, Deserializer};
use super::parameter::OpenApiParameter;
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum OpenApiPathParameter<'a> {
Link {
#[serde(rename = "$ref")]
ref_path: &'a str,
},
Inline(OpenApiParameter<'a>),
}
#[derive(Debug, Clone, Deserialize)]
pub struct SchemaLink<'a> {
#[serde(rename = "$ref")]
pub ref_path: &'a str,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum OpenApiResponseBody<'a> {
Schema(SchemaLink<'a>),
Union {
#[serde(borrow, rename = "anyOf")]
any_of: Vec<SchemaLink<'a>>,
},
}
fn deserialize_response_body<'de, D>(deserializer: D) -> Result<OpenApiResponseBody<'de>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Json<'a> {
#[serde(borrow)]
schema: OpenApiResponseBody<'a>,
}
#[derive(Deserialize)]
struct Content<'a> {
#[serde(borrow, rename = "application/json")]
json: Json<'a>,
}
#[derive(Deserialize)]
struct StatusOk<'a> {
#[serde(borrow)]
content: Content<'a>,
}
#[derive(Deserialize)]
struct Responses<'a> {
#[serde(borrow, rename = "200")]
ok: StatusOk<'a>,
}
let responses = Responses::deserialize(deserializer)?;
Ok(responses.ok.content.json.schema)
}
#[derive(Debug, Clone, Deserialize)]
pub struct OpenApiPathBody<'a> {
pub summary: Option<Cow<'a, str>>,
pub description: Cow<'a, str>,
#[serde(borrow, default)]
pub parameters: Vec<OpenApiPathParameter<'a>>,
#[serde(
borrow,
rename = "responses",
deserialize_with = "deserialize_response_body"
)]
pub response_content: OpenApiResponseBody<'a>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OpenApiPath<'a> {
#[serde(borrow)]
pub get: OpenApiPathBody<'a>,
}

View file

@ -0,0 +1,38 @@
use indexmap::IndexMap;
use serde::Deserialize;
use super::{parameter::OpenApiParameter, path::OpenApiPath, r#type::OpenApiType};
#[derive(Debug, Clone, Deserialize)]
pub struct Components<'a> {
#[serde(borrow)]
pub schemas: IndexMap<&'a str, OpenApiType<'a>>,
#[serde(borrow)]
pub parameters: IndexMap<&'a str, OpenApiParameter<'a>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OpenApiSchema<'a> {
#[serde(borrow)]
pub paths: IndexMap<&'a str, OpenApiPath<'a>>,
#[serde(borrow)]
pub components: Components<'a>,
}
impl OpenApiSchema<'_> {
pub fn read() -> Result<Self, serde_json::Error> {
let s = include_str!("../../openapi.json");
serde_json::from_str(s)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn read() {
OpenApiSchema::read().unwrap();
}
}

View file

@ -0,0 +1,98 @@
use std::borrow::Cow;
use indexmap::IndexMap;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
pub enum OpenApiVariants<'a> {
Int(Vec<i32>),
#[serde(borrow)]
Str(Vec<&'a str>),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenApiType<'a> {
#[serde(default)]
pub deprecated: bool,
pub description: Option<Cow<'a, str>>,
pub r#type: Option<&'a str>,
pub format: Option<&'a str>,
#[serde(rename = "$ref")]
pub ref_path: Option<&'a str>,
pub one_of: Option<Vec<OpenApiType<'a>>>,
pub all_of: Option<Vec<OpenApiType<'a>>>,
pub required: Option<Vec<&'a str>>,
#[serde(borrow)]
pub properties: Option<IndexMap<&'a str, OpenApiType<'a>>>,
pub items: Option<Box<OpenApiType<'a>>>,
pub r#enum: Option<OpenApiVariants<'a>>,
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn object() {
let json = r##"
{
"required": [
"name",
"branches"
],
"properties": {
"name": {
"type": "string"
},
"branches": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TornFactionTreeBranch"
}
}
},
"type": "object"
}
"##;
let obj: OpenApiType = serde_json::from_str(json).unwrap();
assert_eq!(obj.r#type, Some("object"));
let props = obj.properties.unwrap();
assert!(props.contains_key("name"));
let branches = props.get("branches").unwrap();
assert_eq!(branches.r#type, Some("array"));
let items = branches.items.as_ref().unwrap();
assert!(items.ref_path.is_some());
}
#[test]
fn enum_variants() {
let int_json = r#"
[1, 2, 3, 4]
"#;
let de: OpenApiVariants = serde_json::from_str(int_json).unwrap();
assert_eq!(de, OpenApiVariants::Int(vec![1, 2, 3, 4]));
let str_json = r#"
["foo", "bar", "baz"]
"#;
let de: OpenApiVariants = serde_json::from_str(str_json).unwrap();
assert_eq!(de, OpenApiVariants::Str(vec!["foo", "bar", "baz"]));
}
}

View file

@ -1,51 +1,27 @@
[package]
name = "torn-api"
version = "0.7.5"
edition = "2021"
rust-version = "1.75.0"
authors = ["Pyrit [2111649]"]
license = "MIT"
repository = "https://github.com/TotallyNot/torn-api.rs.git"
homepage = "https://github.com/TotallyNot/torn-api.rs.git"
description = "Torn API bindings for rust"
[[bench]]
name = "deserialisation_benchmark"
harness = false
[features]
default = [ "reqwest", "user", "faction", "torn", "key", "market" ]
reqwest = [ "dep:reqwest" ]
awc = [ "dep:awc" ]
decimal = [ "dep:rust_decimal" ]
user = [ "__common" ]
faction = [ "__common" ]
torn = [ "__common" ]
market = [ "__common" ]
key = []
__common = []
version = "1.0.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = [ "derive" ] }
serde_json = "1"
chrono = { version = "0.4.31", features = [ "serde" ], default-features = false }
async-trait = "0.1"
thiserror = "1"
futures = "0.3"
reqwest = { version = "0.12", default-features = false, features = [ "json" ], optional = true }
awc = { version = "3", default-features = false, optional = true }
rust_decimal = { version = "1", default-features = false, optional = true, features = [ "serde" ] }
torn-api-macros = { path = "../torn-api-macros", version = "0.3.1" }
serde = { workspace = true, features = ["derive"] }
serde_repr = "0.1"
serde_json = { workspace = true }
bon = "3.6"
bytes = "1"
http = "1"
reqwest = { version = "0.12", default-features = false, features = [
"rustls-tls",
"json",
"brotli",
] }
thiserror = "2"
[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 = "0.12", default-features = true }
awc = { version = "3", features = [ "rustls" ] }
criterion = "0.5"
tokio = { version = "1", features = ["full"] }
[build-dependencies]
torn-api-codegen = { path = "../torn-api-codegen" }
syn = { workspace = true, features = ["parsing"] }
proc-macro2 = { workspace = true }
prettyplease = "0.2"

View file

@ -1,90 +0,0 @@
use criterion::{criterion_group, criterion_main, Criterion};
use torn_api::{faction, send::ApiClient, user};
pub fn user_benchmark(c: &mut Criterion) {
dotenv::dotenv().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap();
let response = rt.block_on(async {
let key = std::env::var("APIKEY").expect("api key");
let client = reqwest::Client::default();
client
.torn_api(key)
.user(|b| {
b.selections([
user::Selection::Basic,
user::Selection::Discord,
user::Selection::Profile,
user::Selection::PersonalStats,
])
})
.await
.unwrap()
});
c.bench_function("user deserialize", |b| {
b.iter(|| {
response.basic().unwrap();
response.discord().unwrap();
response.profile().unwrap();
response.personal_stats().unwrap();
})
});
}
pub fn faction_benchmark(c: &mut Criterion) {
dotenv::dotenv().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap();
let response = rt.block_on(async {
let key = std::env::var("APIKEY").expect("api key");
let client = reqwest::Client::default();
client
.torn_api(key)
.faction(|b| b.selections([faction::Selection::Basic]))
.await
.unwrap()
});
c.bench_function("faction deserialize", |b| {
b.iter(|| {
response.basic().unwrap();
})
});
}
pub fn attacks_full(c: &mut Criterion) {
dotenv::dotenv().unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap();
let response = rt.block_on(async {
let key = std::env::var("APIKEY").expect("api key");
let client = reqwest::Client::default();
client
.torn_api(key)
.faction(|b| b.selections([faction::Selection::AttacksFull]))
.await
.unwrap()
});
c.bench_function("attacksfull deserialize", |b| {
b.iter(|| {
response.attacks_full().unwrap();
})
});
}
criterion_group!(benches, user_benchmark, faction_benchmark, attacks_full);
criterion_main!(benches);

75
torn-api/build.rs Normal file
View file

@ -0,0 +1,75 @@
use std::{env, fs, path::Path};
use proc_macro2::TokenStream;
use torn_api_codegen::{
model::{parameter::Parameter, path::Path as ApiPath, resolve, scope::Scope},
openapi::schema::OpenApiSchema,
};
const DENY_LIST: &[&str] = &[];
fn main() {
let out_dir = env::var_os("OUT_DIR").unwrap();
let model_dest = Path::new(&out_dir).join("models.rs");
let params_dest = Path::new(&out_dir).join("parameters.rs");
let requests_dest = Path::new(&out_dir).join("requests.rs");
let scopes_dest = Path::new(&out_dir).join("scopes.rs");
let schema = OpenApiSchema::read().unwrap();
let mut models_code = TokenStream::new();
for (name, model) in &schema.components.schemas {
if DENY_LIST.contains(name) {
continue;
}
let model = resolve(model, name, &schema.components.schemas);
if let Some(new_code) = model.codegen() {
models_code.extend(new_code);
}
}
let models_file = syn::parse2(models_code).unwrap();
let models_pretty = prettyplease::unparse(&models_file);
fs::write(&model_dest, models_pretty).unwrap();
let mut params_code = TokenStream::new();
for (name, param) in &schema.components.parameters {
if let Some(code) = Parameter::from_schema(name, param).unwrap().codegen() {
params_code.extend(code);
}
}
let params_file = syn::parse2(params_code).unwrap();
let params_pretty = prettyplease::unparse(&params_file);
fs::write(&params_dest, params_pretty).unwrap();
let mut requests_code = TokenStream::new();
let mut paths = Vec::new();
for (name, path) in &schema.paths {
let Some(path) = ApiPath::from_schema(name, path, &schema.components.parameters) else {
continue;
};
if let Some(code) = path.codegen_request() {
requests_code.extend(code);
}
paths.push(path);
}
let requests_file = syn::parse2(requests_code).unwrap();
let requests_pretty = prettyplease::unparse(&requests_file);
fs::write(&requests_dest, requests_pretty).unwrap();
let mut scope_code = TokenStream::new();
let scopes = Scope::from_paths(paths);
for scope in scopes {
if let Some(code) = scope.codegen() {
scope_code.extend(code);
}
}
let scopes_file = syn::parse2(scope_code).unwrap();
let scopes_pretty = prettyplease::unparse(&scopes_file);
fs::write(&scopes_dest, scopes_pretty).unwrap();
}

View file

@ -1,22 +0,0 @@
use async_trait::async_trait;
use thiserror::Error;
use crate::local::ApiClient;
#[derive(Error, Debug)]
pub enum AwcApiClientError {
#[error(transparent)]
Client(#[from] awc::error::SendRequestError),
#[error(transparent)]
Payload(#[from] awc::error::JsonPayloadError),
}
#[async_trait(?Send)]
impl ApiClient for awc::Client {
type Error = AwcApiClientError;
async fn request(&self, url: String) -> Result<serde_json::Value, Self::Error> {
self.get(url).send().await?.json().await.map_err(Into::into)
}
}

View file

@ -1,172 +0,0 @@
use chrono::{serde::ts_seconds, DateTime, Utc};
use serde::Deserialize;
use torn_api_macros::IntoOwned;
use crate::de_util;
#[derive(Debug, Clone, Deserialize)]
pub enum OnlineStatus {
Online,
Offline,
Idle,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LastAction {
#[serde(with = "ts_seconds")]
pub timestamp: DateTime<Utc>,
pub status: OnlineStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum State {
Okay,
Traveling,
Hospital,
Abroad,
Jail,
Federal,
Fallen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StateColour {
Green,
Red,
Blue,
}
#[derive(Debug, IntoOwned, Deserialize)]
pub struct Status<'a> {
pub description: &'a str,
#[serde(deserialize_with = "de_util::empty_string_is_none")]
pub details: Option<&'a str>,
#[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 Territory {
pub sector: i16,
pub size: i16,
pub density: i16,
pub daily_respect: i16,
pub faction: i32,
#[cfg(feature = "decimal")]
#[serde(deserialize_with = "de_util::string_or_decimal")]
pub coordinate_x: rust_decimal::Decimal,
#[cfg(feature = "decimal")]
#[serde(deserialize_with = "de_util::string_or_decimal")]
pub coordinate_y: rust_decimal::Decimal,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub enum AttackResult {
Attacked,
Mugged,
Hospitalized,
Lost,
Arrested,
Escape,
Interrupted,
Assist,
Timeout,
Stalemate,
Special,
Looted,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Attack<'a> {
pub code: &'a str,
#[serde(with = "ts_seconds")]
pub timestamp_started: DateTime<Utc>,
#[serde(with = "ts_seconds")]
pub timestamp_ended: DateTime<Utc>,
#[serde(deserialize_with = "de_util::empty_string_int_option")]
pub attacker_id: Option<i32>,
#[serde(deserialize_with = "de_util::empty_string_int_option")]
pub attacker_faction: Option<i32>,
pub defender_id: i32,
#[serde(deserialize_with = "de_util::empty_string_int_option")]
pub defender_faction: Option<i32>,
pub result: AttackResult,
#[serde(deserialize_with = "de_util::int_is_bool")]
pub stealthed: bool,
#[cfg(feature = "decimal")]
pub respect: rust_decimal::Decimal,
#[cfg(not(feature = "decimal"))]
pub respect: f32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RespectModifiers {
pub fair_fight: f32,
pub war: f32,
pub retaliation: f32,
pub group_attack: f32,
pub overseas: f32,
pub chain_bonus: f32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AttackFull<'a> {
pub code: &'a str,
#[serde(with = "ts_seconds")]
pub timestamp_started: DateTime<Utc>,
#[serde(with = "ts_seconds")]
pub timestamp_ended: DateTime<Utc>,
#[serde(deserialize_with = "de_util::empty_string_int_option")]
pub attacker_id: Option<i32>,
#[serde(deserialize_with = "de_util::empty_string_is_none")]
pub attacker_name: Option<&'a str>,
#[serde(deserialize_with = "de_util::empty_string_int_option")]
pub attacker_faction: Option<i32>,
#[serde(
deserialize_with = "de_util::empty_string_is_none",
rename = "attacker_factionname"
)]
pub attacker_faction_name: Option<&'a str>,
pub defender_id: i32,
pub defender_name: &'a str,
#[serde(deserialize_with = "de_util::empty_string_int_option")]
pub defender_faction: Option<i32>,
#[serde(
deserialize_with = "de_util::empty_string_is_none",
rename = "defender_factionname"
)]
pub defender_faction_name: Option<&'a str>,
pub result: AttackResult,
#[serde(deserialize_with = "de_util::int_is_bool")]
pub stealthed: bool,
#[serde(deserialize_with = "de_util::int_is_bool")]
pub raid: bool,
#[serde(deserialize_with = "de_util::int_is_bool")]
pub ranked_war: bool,
#[cfg(feature = "decimal")]
pub respect: rust_decimal::Decimal,
#[cfg(feature = "decimal")]
pub respect_loss: rust_decimal::Decimal,
#[cfg(not(feature = "decimal"))]
pub respect: f32,
#[cfg(not(feature = "decimal"))]
pub respect_loss: f32,
pub modifiers: RespectModifiers,
}

View file

@ -1,245 +0,0 @@
#![allow(unused)]
use std::collections::{BTreeMap, HashMap};
use chrono::{serde::ts_nanoseconds::deserialize, DateTime, NaiveDateTime, Utc};
use serde::de::{Deserialize, Deserializer, Error, Unexpected, Visitor};
pub(crate) fn empty_string_is_none<'de, D>(deserializer: D) -> Result<Option<&'de str>, D::Error>
where
D: Deserializer<'de>,
{
let s: &str = Deserialize::deserialize(deserializer)?;
if s.is_empty() {
Ok(None)
} else {
Ok(Some(s))
}
}
pub(crate) fn string_is_long<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.is_empty() {
Ok(None)
} else {
s.parse()
.map(Some)
.map_err(|_e| Error::invalid_type(Unexpected::Str(&s), &"i64"))
}
}
pub(crate) 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 {
Ok(DateTime::from_timestamp(i, 0))
}
}
pub(crate) fn int_is_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
let i = i64::deserialize(deserializer)?;
match i {
0 => Ok(false),
1 => Ok(true),
x => Err(Error::invalid_value(Unexpected::Signed(x), &"0 or 1")),
}
}
pub(crate) fn empty_string_int_option<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
where
D: Deserializer<'de>,
{
struct DumbVisitor;
impl<'de> Visitor<'de> for DumbVisitor {
type Value = Option<i32>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "Empty string or integer")
}
// serde_json will treat all unsigned integers as u64
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: Error,
{
Ok(Some(v as i32))
}
fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
where
E: Error,
{
if v.is_empty() {
Ok(None)
} else {
Err(E::invalid_value(Unexpected::Str(v), &self))
}
}
}
deserializer.deserialize_any(DumbVisitor)
}
pub(crate) fn datetime_map<'de, D>(
deserializer: D,
) -> Result<BTreeMap<i32, chrono::DateTime<chrono::Utc>>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct UnixTimestamp(
#[serde(with = "chrono::serde::ts_seconds")] chrono::DateTime<chrono::Utc>,
);
struct MapVisitor;
impl<'de> Visitor<'de> for MapVisitor {
type Value = BTreeMap<i32, chrono::DateTime<chrono::Utc>>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "map of unix timestamps")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut result = BTreeMap::new();
while let Some(key) = map.next_key::<&'de str>()? {
let id = key
.parse()
.map_err(|_e| A::Error::invalid_value(Unexpected::Str(key), &"integer"))?;
let ts: UnixTimestamp = map.next_value()?;
result.insert(id, ts.0);
}
Ok(result)
}
}
deserializer.deserialize_map(MapVisitor)
}
pub(crate) fn empty_dict_is_empty_array<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
struct ArrayVisitor<T>(std::marker::PhantomData<T>);
impl<'de, T> Visitor<'de> for ArrayVisitor<T>
where
T: Deserialize<'de>,
{
type Value = Vec<T>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "vec or empty object")
}
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
match map.size_hint() {
Some(0) | None => Ok(Vec::default()),
Some(len) => Err(A::Error::invalid_length(len, &"empty dict")),
}
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut result = match seq.size_hint() {
Some(len) => Vec::with_capacity(len),
None => Vec::default(),
};
while let Some(element) = seq.next_element()? {
result.push(element);
}
Ok(result)
}
}
deserializer.deserialize_any(ArrayVisitor(std::marker::PhantomData))
}
pub(crate) fn zero_is_none<'de, D, I>(deserializer: D) -> Result<Option<I>, D::Error>
where
D: Deserializer<'de>,
I: TryFrom<i64>,
{
let num = i64::deserialize(deserializer)?;
if num == 0 {
Ok(None)
} else {
Ok(Some(num.try_into().map_err(|_| {
D::Error::invalid_value(Unexpected::Signed(num), &std::any::type_name::<I>())
})?))
}
}
pub(crate) fn null_is_empty_dict<'de, D, K, V>(deserializer: D) -> Result<HashMap<K, V>, D::Error>
where
D: Deserializer<'de>,
K: std::hash::Hash + std::cmp::Eq + Deserialize<'de>,
V: Deserialize<'de>,
{
Ok(Option::deserialize(deserializer)?.unwrap_or_default())
}
#[cfg(feature = "decimal")]
pub(crate) fn string_or_decimal<'de, D>(deserializer: D) -> Result<rust_decimal::Decimal, D::Error>
where
D: Deserializer<'de>,
{
struct DumbVisitor;
impl<'de> Visitor<'de> for DumbVisitor {
type Value = rust_decimal::Decimal;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "integer or float as string")
}
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: Error,
{
Ok(v.into())
}
fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
where
E: Error,
{
Ok(v.into())
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error,
{
rust_decimal::Decimal::from_str_exact(v).map_err(E::custom)
}
}
deserializer.deserialize_any(DumbVisitor)
}

156
torn-api/src/executor.rs Normal file
View file

@ -0,0 +1,156 @@
use http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
use serde::Deserialize;
use crate::{
request::{ApiResponse, IntoRequest},
scopes::{FactionScope, ForumScope, MarketScope, RacingScope, TornScope, UserScope},
};
pub trait Executor {
type Error: From<serde_json::Error> + From<crate::ApiError> + Send;
fn execute<R>(
&self,
request: R,
) -> impl Future<Output = Result<ApiResponse<R::Discriminant>, Self::Error>> + Send
where
R: IntoRequest;
fn fetch<R>(&self, request: R) -> impl Future<Output = Result<R::Response, Self::Error>> + Send
where
R: IntoRequest,
{
// HACK: workaround for not using `async` in trait declaration.
// The future is `Send` but `&self` might not be.
let fut = self.execute(request);
async {
let resp = fut.await?;
let bytes = resp.body.unwrap();
if bytes.starts_with(br#"{"error":{"#) {
#[derive(Deserialize)]
struct ErrorBody<'a> {
code: u16,
error: &'a str,
}
#[derive(Deserialize)]
struct ErrorContainer<'a> {
#[serde(borrow)]
error: ErrorBody<'a>,
}
let error: ErrorContainer = serde_json::from_slice(&bytes)?;
return Err(crate::ApiError::new(error.error.code, error.error.error).into());
}
let resp = serde_json::from_slice(&bytes)?;
Ok(resp)
}
}
}
pub struct ReqwestClient(reqwest::Client);
impl ReqwestClient {
pub fn new(api_key: &str) -> Self {
let mut headers = HeaderMap::with_capacity(1);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("ApiKey {api_key}")).unwrap(),
);
let client = reqwest::Client::builder()
.default_headers(headers)
.brotli(true)
.build()
.unwrap();
Self(client)
}
}
pub trait ExecutorExt: Executor + Sized {
fn user(&self) -> UserScope<'_, Self>;
fn faction(&self) -> FactionScope<'_, Self>;
fn torn(&self) -> TornScope<'_, Self>;
fn market(&self) -> MarketScope<'_, Self>;
fn racing(&self) -> RacingScope<'_, Self>;
fn forum(&self) -> ForumScope<'_, Self>;
}
impl<T> ExecutorExt for T
where
T: Executor + Sized,
{
fn user(&self) -> UserScope<'_, Self> {
UserScope::new(self)
}
fn faction(&self) -> FactionScope<'_, Self> {
FactionScope::new(self)
}
fn torn(&self) -> TornScope<'_, Self> {
TornScope::new(self)
}
fn market(&self) -> MarketScope<'_, Self> {
MarketScope::new(self)
}
fn racing(&self) -> RacingScope<'_, Self> {
RacingScope::new(self)
}
fn forum(&self) -> ForumScope<'_, Self> {
ForumScope::new(self)
}
}
impl Executor for ReqwestClient {
type Error = crate::Error;
async fn execute<R>(&self, request: R) -> Result<ApiResponse<R::Discriminant>, Self::Error>
where
R: IntoRequest,
{
let request = request.into_request();
let url = request.url();
let response = self.0.get(url).send().await?;
let status = response.status();
let body = response.bytes().await.ok();
Ok(ApiResponse {
discriminant: request.disriminant,
status,
body,
})
}
}
#[cfg(test)]
mod test {
use crate::{ApiError, Error, scopes::test::test_client};
use super::*;
#[tokio::test]
async fn api_error() {
let client = test_client().await;
let resp = client.faction().basic_for_id((-1).into(), |b| b).await;
match resp {
Err(Error::Api(ApiError::IncorrectIdEntityRelation)) => (),
other => panic!("Expected incorrect id entity relation error, got {other:?}"),
}
}
}

View file

@ -1,278 +0,0 @@
use std::collections::{BTreeMap, HashMap};
use chrono::{DateTime, TimeZone, Utc};
use serde::{
de::{Error, Unexpected, Visitor},
Deserialize, Deserializer,
};
use torn_api_macros::{ApiCategory, IntoOwned};
use crate::de_util::{self, null_is_empty_dict};
pub use crate::common::{Attack, AttackFull, LastAction, Status, Territory};
#[derive(Debug, Clone, Copy, ApiCategory)]
#[api(category = "faction")]
#[non_exhaustive]
pub enum FactionSelection {
#[api(type = "Basic", flatten)]
Basic,
#[api(type = "BTreeMap<i32, Attack>", field = "attacks")]
AttacksFull,
#[api(type = "BTreeMap<i32, AttackFull>", field = "attacks")]
Attacks,
#[api(
type = "HashMap<String, Territory>",
field = "territory",
with = "null_is_empty_dict"
)]
Territory,
#[api(type = "Option<Chain>", field = "chain", with = "deserialize_chain")]
Chain,
}
pub type Selection = FactionSelection;
#[derive(Debug, IntoOwned, Deserialize)]
pub struct Member<'a> {
pub name: &'a str,
pub level: i16,
pub days_in_faction: i16,
pub position: &'a str,
pub status: Status<'a>,
pub last_action: LastAction,
}
#[derive(Debug, IntoOwned, Deserialize)]
pub struct FactionTerritoryWar<'a> {
pub territory_war_id: i32,
pub territory: &'a str,
pub assaulting_faction: i32,
pub defending_faction: i32,
pub score: i32,
pub required_score: i32,
#[serde(with = "chrono::serde::ts_seconds")]
pub start_time: DateTime<Utc>,
#[serde(with = "chrono::serde::ts_seconds")]
pub end_time: DateTime<Utc>,
}
#[derive(Debug, IntoOwned, Deserialize)]
pub struct Basic<'a> {
#[serde(rename = "ID")]
pub id: i32,
pub name: &'a str,
pub leader: i32,
pub respect: i32,
pub age: i16,
pub capacity: i16,
pub best_chain: i32,
#[serde(deserialize_with = "de_util::empty_string_is_none")]
pub tag_image: Option<&'a str>,
#[serde(borrow)]
pub members: BTreeMap<i32, Member<'a>>,
#[serde(deserialize_with = "de_util::datetime_map")]
pub peace: BTreeMap<i32, DateTime<Utc>>,
#[serde(borrow, deserialize_with = "de_util::empty_dict_is_empty_array")]
pub territory_wars: Vec<FactionTerritoryWar<'a>>,
}
#[derive(Debug)]
pub struct Chain {
pub current: i32,
pub max: i32,
#[cfg(feature = "decimal")]
pub modifier: rust_decimal::Decimal,
pub timeout: Option<i32>,
pub cooldown: Option<i32>,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
fn deserialize_chain<'de, D>(deserializer: D) -> Result<Option<Chain>, D::Error>
where
D: Deserializer<'de>,
{
struct ChainVisitor;
impl<'de> Visitor<'de> for ChainVisitor {
type Value = Option<Chain>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("struct Chain")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum Fields {
Current,
Max,
Modifier,
Timeout,
Cooldown,
Start,
End,
#[serde(other)]
Ignore,
}
let mut current = None;
let mut max = None;
#[cfg(feature = "decimal")]
let mut modifier = None;
let mut timeout = None;
let mut cooldown = None;
let mut start = None;
let mut end = None;
while let Some(key) = map.next_key()? {
match key {
Fields::Current => {
let value = map.next_value()?;
if value != 0 {
current = Some(value);
}
}
Fields::Max => {
max = Some(map.next_value()?);
}
Fields::Modifier => {
#[cfg(feature = "decimal")]
{
modifier = Some(map.next_value()?);
}
}
Fields::Timeout => {
match map.next_value()? {
0 => timeout = Some(None),
val => timeout = Some(Some(val)),
};
}
Fields::Cooldown => {
match map.next_value()? {
0 => cooldown = Some(None),
val => cooldown = Some(Some(val)),
};
}
Fields::Start => {
let ts: i64 = map.next_value()?;
start = Some(Utc.timestamp_opt(ts, 0).single().ok_or_else(|| {
A::Error::invalid_value(Unexpected::Signed(ts), &"Epoch timestamp")
})?);
}
Fields::End => {
let ts: i64 = map.next_value()?;
end = Some(Utc.timestamp_opt(ts, 0).single().ok_or_else(|| {
A::Error::invalid_value(Unexpected::Signed(ts), &"Epoch timestamp")
})?);
}
Fields::Ignore => (),
}
}
let Some(current) = current else {
return Ok(None);
};
let max = max.ok_or_else(|| A::Error::missing_field("max"))?;
let timeout = timeout.ok_or_else(|| A::Error::missing_field("timeout"))?;
let cooldown = cooldown.ok_or_else(|| A::Error::missing_field("cooldown"))?;
let start = start.ok_or_else(|| A::Error::missing_field("start"))?;
let end = end.ok_or_else(|| A::Error::missing_field("end"))?;
Ok(Some(Chain {
current,
max,
#[cfg(feature = "decimal")]
modifier: modifier.ok_or_else(|| A::Error::missing_field("modifier"))?,
timeout,
cooldown,
start,
end,
}))
}
}
deserializer.deserialize_map(ChainVisitor)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{async_test, setup, Client, ClientTrait};
#[async_test]
async fn faction() {
let key = setup();
let response = Client::default()
.torn_api(key)
.faction(|b| {
b.selections([
Selection::Basic,
Selection::Attacks,
Selection::Territory,
Selection::Chain,
])
})
.await
.unwrap();
response.basic().unwrap();
response.attacks().unwrap();
response.attacks_full().unwrap();
response.territory().unwrap();
response.chain().unwrap();
}
#[async_test]
async fn faction_public() {
let key = setup();
let response = Client::default()
.torn_api(key)
.faction(|b| {
b.id(7049)
.selections([Selection::Basic, Selection::Territory, Selection::Chain])
})
.await
.unwrap();
response.basic().unwrap();
response.territory().unwrap();
response.chain().unwrap();
}
#[async_test]
async fn destroyed_faction() {
let key = setup();
let response = Client::default()
.torn_api(key)
.faction(|b| {
b.id(8981)
.selections([Selection::Basic, Selection::Territory, Selection::Chain])
})
.await
.unwrap();
response.basic().unwrap();
response.territory().unwrap();
assert!(response.chain().unwrap().is_none());
}
}

View file

@ -1,79 +0,0 @@
pub use torn_api_macros::IntoOwned;
pub trait IntoOwned {
type Owned;
fn into_owned(self) -> Self::Owned;
}
impl<T> IntoOwned for Option<T>
where
T: IntoOwned,
{
type Owned = Option<T::Owned>;
fn into_owned(self) -> Self::Owned {
self.map(IntoOwned::into_owned)
}
}
impl<T> IntoOwned for Vec<T> where T: IntoOwned {
type Owned = Vec<<T as IntoOwned>::Owned>;
fn into_owned(self) -> Self::Owned {
let mut owned = Vec::with_capacity(self.len());
for elem in self {
owned.push(elem.into_owned());
}
owned
}
}
impl<K, V> IntoOwned for std::collections::HashMap<K, V> where V: IntoOwned, K: Eq + std::hash::Hash {
type Owned = std::collections::HashMap<K, <V as IntoOwned>::Owned>;
fn into_owned(self) -> Self::Owned {
self.into_iter().map(|(k, v)| (k, v.into_owned())).collect()
}
}
impl<K, V> IntoOwned for std::collections::BTreeMap<K, V> where V: IntoOwned, K: Eq + Ord + std::hash::Hash {
type Owned = std::collections::BTreeMap<K, <V as IntoOwned>::Owned>;
fn into_owned(self) -> Self::Owned {
self.into_iter().map(|(k, v)| (k, v.into_owned())).collect()
}
}
impl<Z> IntoOwned for chrono::DateTime<Z> where Z: chrono::TimeZone {
type Owned = Self;
fn into_owned(self) -> Self::Owned {
self
}
}
impl<'a> IntoOwned for &'a str {
type Owned = String;
fn into_owned(self) -> Self::Owned {
self.to_owned()
}
}
macro_rules! impl_ident {
($name:path) => {
impl IntoOwned for $name {
type Owned = $name;
fn into_owned(self) -> Self::Owned {
self
}
}
};
}
impl_ident!(i64);
impl_ident!(i32);
impl_ident!(i16);
impl_ident!(i8);
impl_ident!(String);

View file

@ -1,256 +0,0 @@
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use torn_api_macros::ApiCategory;
#[derive(Debug, Clone, Copy, ApiCategory)]
#[api(category = "key")]
#[non_exhaustive]
pub enum Selection {
#[api(type = "Info", flatten)]
Info,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AccessType {
#[serde(rename = "Custom")]
Custom,
#[serde(rename = "Public Only")]
Public,
#[serde(rename = "Minimal Access")]
Minimal,
#[serde(rename = "Limited Access")]
Limited,
#[serde(rename = "Full Access")]
Full,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum KeySelection {
Info,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum UserSelection {
Ammo,
Attacks,
AttacksFull,
Bars,
Basic,
BattleStats,
Bazaar,
Cooldowns,
Crimes,
Discord,
Display,
Education,
Events,
Gym,
Hof,
Honors,
Icons,
Inventory,
JobPoints,
Log,
Medals,
Merits,
Messages,
Missions,
Money,
Networth,
NewEvents,
NewMessages,
Notifications,
Perks,
PersonalStats,
Profile,
Properties,
ReceivedEvents,
Refills,
Reports,
Revives,
RevivesFull,
Skills,
Stocks,
Timestamp,
Travel,
WeaponExp,
WorkStats,
Lookup,
PublicStatus,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum FactionSelection {
Applications,
Armor,
ArmoryNews,
AttackNews,
Attacks,
AttacksFull,
Basic,
Boosters,
Cesium,
Chain,
ChainReport,
Chains,
Contributors,
Crimenews,
Crimes,
Currency,
Donations,
Drugs,
FundsNews,
MainNews,
Medical,
MembershipNews,
Positions,
Reports,
Revives,
RevivesFull,
Stats,
Temporary,
Territory,
TerritoryNews,
Timestamp,
Upgrades,
Weapons,
Lookup,
Caches,
CrimeExp,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum CompanySelection {
Applications,
Companies,
Detailed,
Employees,
News,
NewsFull,
Profile,
Stock,
Timestamp,
Lookup,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum TornSelection {
Bank,
Cards,
ChainReport,
Companies,
Competition,
Education,
FactionTree,
Gyms,
Honors,
Items,
ItemStats,
LogCategories,
LogTypes,
Medals,
OrganisedCrimes,
PawnShop,
PokerTables,
Properties,
Rackets,
Raids,
RankedWars,
RankedWarReport,
Stats,
Stocks,
Territory,
TerritoryWars,
Timestamp,
Lookup,
CityShops,
ItemDetails,
TerritoryNames,
TerritoryWarReport,
RaidReport,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum MarketSelection {
Bazaar,
ItemMarket,
PointsMarket,
Timestamp,
Lookup,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum PropertySelection {
Property,
Timestamp,
Lookup,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Selections {
pub user: HashSet<UserSelection>,
pub faction: HashSet<FactionSelection>,
pub company: HashSet<CompanySelection>,
pub torn: HashSet<TornSelection>,
pub market: HashSet<MarketSelection>,
pub property: HashSet<PropertySelection>,
pub key: HashSet<KeySelection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Info {
pub access_level: i16,
pub access_type: AccessType,
pub selections: Selections,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{async_test, setup, Client, ClientTrait};
#[async_test]
async fn key() {
let key = setup();
let response = Client::default()
.torn_api(key)
.key(|b| b.selections([Selection::Info]))
.await
.unwrap();
response.info().unwrap();
}
}

View file

@ -1,460 +1,150 @@
#![warn(clippy::all, clippy::perf, clippy::style, clippy::suspicious)]
pub mod into_owned;
pub mod local;
pub mod send;
#[cfg(feature = "user")]
pub mod user;
#[cfg(feature = "faction")]
pub mod faction;
#[cfg(feature = "market")]
pub mod market;
#[cfg(feature = "torn")]
pub mod torn;
#[cfg(feature = "key")]
pub mod key;
#[cfg(feature = "awc")]
pub mod awc;
#[cfg(feature = "reqwest")]
pub mod reqwest;
#[cfg(feature = "__common")]
pub mod common;
mod de_util;
use std::fmt::Write;
use chrono::{DateTime, Utc};
use serde::{de::Error as DeError, Deserialize};
use thiserror::Error;
pub use into_owned::IntoOwned;
pub mod executor;
pub mod models;
pub mod parameters;
pub mod request;
pub mod scopes;
pub struct ApiResponse {
pub value: serde_json::Value,
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ApiError {
#[error("Unhandled error, should not occur")]
Unknown,
#[error("Private key is empty in current request")]
KeyIsEmpty,
#[error("Private key is wrong/incorrect format")]
IncorrectKey,
#[error("Requesting an incorrect basic type")]
WrongType,
#[error("Requesting incorect selection fields")]
WrongFields,
#[error(
"Requests are blocked for a small period of time because of too many requests per user"
)]
TooManyRequest,
#[error("Wrong ID value")]
IncorrectId,
#[error("A requested selection is private")]
IncorrectIdEntityRelation,
#[error("Current IP is banned for a small period of time because of abuse")]
IpBlock,
#[error("Api system is currently disabled")]
ApiDisabled,
#[error("Current key can't be used because owner is in federal jail")]
KeyOwnerInFederalJail,
#[error("You can only change your API key once every 60 seconds")]
KeyChange,
#[error("Error reading key from Database")]
KeyRead,
#[error("The key owner hasn't been online for more than 7 days")]
TemporaryInactivity,
#[error("Too many records have been pulled today by this user from our cloud services")]
DailyReadLimit,
#[error("An error code specifically for testing purposes that has no dedicated meaning")]
TemporaryError,
#[error("A selection is being called of which this key does not have permission to access")]
InsufficientAccessLevel,
#[error("Backend error occurred, please try again")]
Backend,
#[error("API key has been paused by the owner")]
Paused,
#[error("Must be migrated to crimes 2.0")]
NotMigratedCrimes,
#[error("Race not yet finished")]
RaceNotFinished,
#[error("Wrong cat value")]
IncorrectCategory,
#[error("This selection is only available in API v1")]
OnlyInV1,
#[error("This selection is only available in API v2")]
OnlyInV2,
#[error("Closed temporarily")]
ClosedTemporarily,
#[error("Other: {message}")]
Other { code: u16, message: String },
}
#[derive(Error, Debug)]
pub enum ResponseError {
#[error("API: {reason}")]
Api { code: u8, reason: String },
impl ApiError {
pub fn new(code: u16, message: &str) -> Self {
match code {
0 => Self::Unknown,
1 => Self::KeyIsEmpty,
2 => Self::IncorrectKey,
3 => Self::WrongType,
4 => Self::WrongFields,
5 => Self::TooManyRequest,
6 => Self::IncorrectId,
7 => Self::IncorrectIdEntityRelation,
8 => Self::IpBlock,
9 => Self::ApiDisabled,
10 => Self::KeyOwnerInFederalJail,
11 => Self::KeyChange,
12 => Self::KeyRead,
13 => Self::TemporaryInactivity,
14 => Self::DailyReadLimit,
15 => Self::TemporaryError,
16 => Self::InsufficientAccessLevel,
17 => Self::Backend,
18 => Self::Paused,
19 => Self::NotMigratedCrimes,
20 => Self::RaceNotFinished,
21 => Self::IncorrectCategory,
22 => Self::OnlyInV1,
23 => Self::OnlyInV2,
24 => Self::ClosedTemporarily,
other => Self::Other {
code: other,
message: message.to_owned(),
},
}
}
#[error(transparent)]
MalformedResponse(#[from] serde_json::Error),
}
impl ResponseError {
pub fn api_code(&self) -> Option<u8> {
pub fn code(&self) -> u16 {
match self {
Self::Api { code, .. } => Some(*code),
_ => None,
Self::Unknown => 0,
Self::KeyIsEmpty => 1,
Self::IncorrectKey => 2,
Self::WrongType => 3,
Self::WrongFields => 4,
Self::TooManyRequest => 5,
Self::IncorrectId => 6,
Self::IncorrectIdEntityRelation => 7,
Self::IpBlock => 8,
Self::ApiDisabled => 9,
Self::KeyOwnerInFederalJail => 10,
Self::KeyChange => 11,
Self::KeyRead => 12,
Self::TemporaryInactivity => 13,
Self::DailyReadLimit => 14,
Self::TemporaryError => 15,
Self::InsufficientAccessLevel => 16,
Self::Backend => 17,
Self::Paused => 18,
Self::NotMigratedCrimes => 19,
Self::RaceNotFinished => 20,
Self::IncorrectCategory => 21,
Self::OnlyInV1 => 22,
Self::OnlyInV2 => 23,
Self::ClosedTemporarily => 24,
Self::Other { code, .. } => *code,
}
}
}
impl ApiResponse {
pub fn from_value(mut value: serde_json::Value) -> Result<Self, ResponseError> {
#[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(ResponseError::Api {
code: dto.code,
reason: dto.reason,
})
}
None => Ok(Self { value }),
}
}
#[allow(dead_code)]
fn decode<'de, D>(&'de self) -> serde_json::Result<D>
where
D: Deserialize<'de>,
{
D::deserialize(&self.value)
}
#[allow(dead_code)]
fn decode_field<'de, D>(&'de self, field: &'static str) -> serde_json::Result<D>
where
D: Deserialize<'de>,
{
self.value
.get(field)
.ok_or_else(|| serde_json::Error::missing_field(field))
.and_then(D::deserialize)
}
#[allow(dead_code)]
fn decode_field_with<'de, V, F>(&'de self, field: &'static str, fun: F) -> serde_json::Result<V>
where
F: FnOnce(&'de serde_json::Value) -> serde_json::Result<V>,
{
self.value
.get(field)
.ok_or_else(|| serde_json::Error::missing_field(field))
.and_then(fun)
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ParameterError {
#[error("value `{value}` is out of range for parameter {name}")]
OutOfRange { name: &'static str, value: i32 },
}
pub trait ApiSelectionResponse: Send + Sync + From<ApiResponse> + 'static {
fn into_inner(self) -> ApiResponse;
}
pub trait ApiSelection: Send + Sync + 'static {
type Response: ApiSelectionResponse;
fn raw_value(self) -> &'static str;
fn category() -> &'static str;
}
pub struct DirectExecutor<C> {
key: String,
_marker: std::marker::PhantomData<C>,
}
impl<C> DirectExecutor<C> {
fn new(key: String) -> Self {
Self {
key,
_marker: Default::default(),
}
}
}
#[derive(Error, Debug)]
pub enum ApiClientError<C>
where
C: std::error::Error,
{
#[error(transparent)]
Client(C),
#[error(transparent)]
Response(#[from] ResponseError),
}
impl<C> ApiClientError<C>
where
C: std::error::Error,
{
pub fn api_code(&self) -> Option<u8> {
match self {
Self::Response(err) => err.api_code(),
_ => None,
}
}
}
#[derive(Debug)]
pub struct ApiRequest<A>
where
A: ApiSelection,
{
pub selections: Vec<&'static str>,
pub query_items: Vec<(&'static str, String)>,
pub comment: Option<String>,
phantom: std::marker::PhantomData<A>,
}
impl<A> std::default::Default for ApiRequest<A>
where
A: ApiSelection,
{
fn default() -> Self {
Self {
selections: Vec::default(),
query_items: Vec::default(),
comment: None,
phantom: Default::default(),
}
}
}
impl<A> ApiRequest<A>
where
A: ApiSelection,
{
fn add_query_item(&mut self, name: &'static str, value: impl ToString) {
if let Some((_, old)) = self.query_items.iter_mut().find(|(n, _)| *n == name) {
*old = value.to_string();
} else {
self.query_items.push((name, value.to_string()));
}
}
pub fn url(&self, key: &str, id: Option<&str>) -> String {
let mut url = format!("https://api.torn.com/{}/", A::category());
if let Some(id) = id {
write!(url, "{}", id).unwrap();
}
write!(url, "?selections={}&key={}", self.selections.join(","), key).unwrap();
for (name, value) in &self.query_items {
write!(url, "&{name}={value}").unwrap();
}
if let Some(comment) = &self.comment {
write!(url, "&comment={}", comment).unwrap();
}
url
}
}
pub struct ApiRequestBuilder<A>
where
A: ApiSelection,
{
pub request: ApiRequest<A>,
pub id: Option<String>,
}
impl<A> Default for ApiRequestBuilder<A>
where
A: ApiSelection,
{
fn default() -> Self {
Self {
request: Default::default(),
id: None,
}
}
}
impl<A> ApiRequestBuilder<A>
where
A: ApiSelection,
{
#[must_use]
pub fn selections(mut self, selections: impl IntoIterator<Item = A>) -> Self {
self.request.selections.append(
&mut selections
.into_iter()
.map(ApiSelection::raw_value)
.collect(),
);
self
}
#[must_use]
pub fn from(mut self, from: DateTime<Utc>) -> Self {
self.request.add_query_item("from", from.timestamp());
self
}
#[must_use]
pub fn from_timestamp(mut self, from: i64) -> Self {
self.request.add_query_item("from", from);
self
}
#[must_use]
pub fn to(mut self, to: DateTime<Utc>) -> Self {
self.request.add_query_item("to", to.timestamp());
self
}
#[must_use]
pub fn to_timestamp(mut self, to: i64) -> Self {
self.request.add_query_item("to", to);
self
}
#[must_use]
pub fn stats_timestamp(mut self, ts: i64) -> Self {
self.request.add_query_item("timestamp", ts);
self
}
#[must_use]
pub fn stats_datetime(mut self, dt: DateTime<Utc>) -> Self {
self.request.add_query_item("timestamp", dt.timestamp());
self
}
#[must_use]
pub fn comment(mut self, comment: String) -> Self {
self.request.comment = Some(comment);
self
}
#[must_use]
pub fn id<I>(mut self, id: I) -> Self
where
I: ToString,
{
self.id = Some(id.to_string());
self
}
}
#[cfg(test)]
#[allow(unused)]
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 crate::local::ApiClient as ClientTrait;
#[cfg(feature = "reqwest")]
pub use crate::send::ApiClient as ClientTrait;
#[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")
}
#[cfg(feature = "user")]
#[test]
fn selection_raw_value() {
assert_eq!(user::Selection::Basic.raw_value(), "basic");
}
#[cfg(all(feature = "reqwest", feature = "user"))]
#[tokio::test]
async fn reqwest() {
let key = setup();
Client::default().torn_api(key).user(|b| b).await.unwrap();
}
#[cfg(all(feature = "awc", feature = "user"))]
#[actix_rt::test]
async fn awc() {
let key = setup();
Client::default().torn_api(key).user(|b| b).await.unwrap();
}
#[test]
fn url_builder_from_dt() {
let url = ApiRequestBuilder::<user::Selection>::default()
.from(DateTime::default())
.request
.url("", None);
assert_eq!("https://api.torn.com/user/?selections=&key=&from=0", url);
}
#[test]
fn url_builder_from_ts() {
let url = ApiRequestBuilder::<user::Selection>::default()
.from_timestamp(12345)
.request
.url("", None);
assert_eq!(
"https://api.torn.com/user/?selections=&key=&from=12345",
url
);
}
#[test]
fn url_builder_to_dt() {
let url = ApiRequestBuilder::<user::Selection>::default()
.to(DateTime::default())
.request
.url("", None);
assert_eq!("https://api.torn.com/user/?selections=&key=&to=0", url);
}
#[test]
fn url_builder_to_ts() {
let url = ApiRequestBuilder::<user::Selection>::default()
.to_timestamp(12345)
.request
.url("", None);
assert_eq!("https://api.torn.com/user/?selections=&key=&to=12345", url);
}
#[test]
fn url_builder_timestamp_dt() {
let url = ApiRequestBuilder::<user::Selection>::default()
.stats_datetime(DateTime::default())
.request
.url("", None);
assert_eq!(
"https://api.torn.com/user/?selections=&key=&timestamp=0",
url
);
}
#[test]
fn url_builder_timestamp_ts() {
let url = ApiRequestBuilder::<user::Selection>::default()
.stats_timestamp(12345)
.request
.url("", None);
assert_eq!(
"https://api.torn.com/user/?selections=&key=&timestamp=12345",
url
);
}
#[test]
fn url_builder_duplicate() {
let url = ApiRequestBuilder::<user::Selection>::default()
.from(DateTime::default())
.from_timestamp(12345)
.request
.url("", None);
assert_eq!(
"https://api.torn.com/user/?selections=&key=&from=12345",
url
);
}
#[test]
fn url_builder_many_options() {
let url = ApiRequestBuilder::<user::Selection>::default()
.from(DateTime::default())
.to_timestamp(60)
.stats_timestamp(12345)
.selections([user::Selection::PersonalStats])
.request
.url("KEY", Some("1"));
assert_eq!(
"https://api.torn.com/user/1?selections=personalstats&key=KEY&from=0&to=60&timestamp=12345",
url
);
}
#[derive(Debug, Error)]
pub enum Error {
#[error("Parameter error: {0}")]
Parameter(#[from] ParameterError),
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Parsing error: {0}")]
Parsing(#[from] serde_json::Error),
#[error("Api error: {0}")]
Api(#[from] ApiError),
}

View file

@ -1,282 +0,0 @@
use std::collections::HashMap;
use async_trait::async_trait;
use crate::{ApiClientError, ApiRequest, ApiResponse, ApiSelection, DirectExecutor};
pub struct ApiProvider<'a, C, E>
where
C: ApiClient,
E: RequestExecutor<C>,
{
#[allow(dead_code)]
client: &'a C,
#[allow(dead_code)]
executor: E,
}
impl<'a, C, E> ApiProvider<'a, C, E>
where
C: ApiClient,
E: RequestExecutor<C>,
{
pub fn new(client: &'a C, executor: E) -> ApiProvider<'a, C, E> {
Self { client, executor }
}
#[cfg(feature = "user")]
pub async fn user<F>(&self, build: F) -> Result<crate::user::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::user::Selection>,
) -> crate::ApiRequestBuilder<crate::user::Selection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
#[cfg(feature = "user")]
pub async fn users<F, L, I>(
&self,
ids: L,
build: F,
) -> HashMap<I, Result<crate::user::Response, E::Error>>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::user::Selection>,
) -> crate::ApiRequestBuilder<crate::user::Selection>,
I: ToString + std::hash::Hash + std::cmp::Eq,
L: IntoIterator<Item = I>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute_many(self.client, builder.request, Vec::from_iter(ids))
.await
}
#[cfg(feature = "faction")]
pub async fn faction<F>(&self, build: F) -> Result<crate::faction::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::faction::Selection>,
) -> crate::ApiRequestBuilder<crate::faction::Selection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
#[cfg(feature = "faction")]
pub async fn factions<F, L, I>(
&self,
ids: L,
build: F,
) -> HashMap<I, Result<crate::faction::Response, E::Error>>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::faction::Selection>,
) -> crate::ApiRequestBuilder<crate::faction::Selection>,
I: ToString + std::hash::Hash + std::cmp::Eq,
L: IntoIterator<Item = I>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute_many(self.client, builder.request, Vec::from_iter(ids))
.await
}
#[cfg(feature = "market")]
pub async fn market<F>(&self, build: F) -> Result<crate::market::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::market::MarketSelection>,
) -> crate::ApiRequestBuilder<crate::market::MarketSelection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
#[cfg(feature = "market")]
pub async fn markets<F, L, I>(
&self,
ids: L,
build: F,
) -> HashMap<I, Result<crate::market::Response, E::Error>>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::market::MarketSelection>,
) -> crate::ApiRequestBuilder<crate::market::MarketSelection>,
I: ToString + std::hash::Hash + std::cmp::Eq,
L: IntoIterator<Item = I>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute_many(self.client, builder.request, Vec::from_iter(ids))
.await
}
#[cfg(feature = "torn")]
pub async fn torn<F>(&self, build: F) -> Result<crate::torn::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::torn::Selection>,
) -> crate::ApiRequestBuilder<crate::torn::Selection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
#[cfg(feature = "torn")]
pub async fn torns<F, L, I>(
&self,
ids: L,
build: F,
) -> HashMap<I, Result<crate::torn::Response, E::Error>>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::torn::Selection>,
) -> crate::ApiRequestBuilder<crate::torn::Selection>,
I: ToString + std::hash::Hash + std::cmp::Eq,
L: IntoIterator<Item = I>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute_many(self.client, builder.request, Vec::from_iter(ids))
.await
}
#[cfg(feature = "key")]
pub async fn key<F>(&self, build: F) -> Result<crate::key::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::key::Selection>,
) -> crate::ApiRequestBuilder<crate::key::Selection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
}
#[async_trait(?Send)]
pub trait RequestExecutor<C>
where
C: ApiClient,
{
type Error: std::error::Error;
async fn execute<A>(
&self,
client: &C,
request: ApiRequest<A>,
id: Option<String>,
) -> Result<A::Response, Self::Error>
where
A: ApiSelection;
async fn execute_many<A, I>(
&self,
client: &C,
request: ApiRequest<A>,
ids: Vec<I>,
) -> HashMap<I, Result<A::Response, Self::Error>>
where
A: ApiSelection,
I: ToString + std::hash::Hash + std::cmp::Eq;
}
#[async_trait(?Send)]
impl<C> RequestExecutor<C> for DirectExecutor<C>
where
C: ApiClient,
{
type Error = ApiClientError<C::Error>;
async fn execute<A>(
&self,
client: &C,
request: ApiRequest<A>,
id: Option<String>,
) -> Result<A::Response, Self::Error>
where
A: ApiSelection,
{
let url = request.url(&self.key, id.as_deref());
let value = client.request(url).await.map_err(ApiClientError::Client)?;
Ok(ApiResponse::from_value(value)?.into())
}
async fn execute_many<A, I>(
&self,
client: &C,
request: ApiRequest<A>,
ids: Vec<I>,
) -> HashMap<I, Result<A::Response, Self::Error>>
where
A: ApiSelection,
I: ToString + std::hash::Hash + std::cmp::Eq,
{
let request_ref = &request;
let tuples = futures::future::join_all(ids.into_iter().map(|i| async move {
let id_string = i.to_string();
let url = request_ref.url(&self.key, Some(&id_string));
let value = client.request(url).await.map_err(ApiClientError::Client);
(
i,
value.and_then(|v| {
ApiResponse::from_value(v)
.map(Into::into)
.map_err(Into::into)
}),
)
}))
.await;
HashMap::from_iter(tuples)
}
}
#[async_trait(?Send)]
pub trait ApiClient {
type Error: std::error::Error;
async fn request(&self, url: String) -> Result<serde_json::Value, Self::Error>;
fn torn_api<S>(&self, key: S) -> ApiProvider<Self, DirectExecutor<Self>>
where
Self: Sized,
S: ToString,
{
ApiProvider::new(self, DirectExecutor::new(key.to_string()))
}
}

View file

@ -1,34 +0,0 @@
use serde::Deserialize;
use torn_api_macros::ApiCategory;
#[derive(Debug, Clone, Copy, ApiCategory)]
#[api(category = "market")]
pub enum MarketSelection {
#[api(type = "Vec<BazaarItem>", field = "bazaar")]
Bazaar,
}
#[derive(Clone, Debug, Deserialize)]
pub struct BazaarItem {
pub cost: u64,
pub quantity: u32,
}
#[cfg(test)]
mod test {
use super::*;
use crate::tests::{async_test, setup, Client, ClientTrait};
#[async_test]
async fn market_bazaar() {
let key = setup();
let response = Client::default()
.torn_api(key)
.market(|b| b.id(1).selections([MarketSelection::Bazaar]))
.await
.unwrap();
_ = response.bazaar().unwrap();
}
}

1
torn-api/src/models.rs Normal file
View file

@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/models.rs"));

View file

@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/parameters.rs"));

104
torn-api/src/request/mod.rs Normal file
View file

@ -0,0 +1,104 @@
use bon::Builder;
use bytes::Bytes;
use http::StatusCode;
use crate::{
executor::Executor,
models::{FactionChainsResponse, FactionId},
};
pub mod models;
#[derive(Default)]
pub struct ApiRequest<D = ()> {
pub disriminant: D,
pub path: String,
pub parameters: Vec<(&'static str, String)>,
}
impl<D> ApiRequest<D> {
pub fn url(&self) -> String {
let mut url = format!("https://api.torn.com/v2{}?", self.path);
for (name, value) in &self.parameters {
url.push_str(&format!("{name}={value}"));
}
url
}
}
pub struct ApiResponse<D = ()> {
pub discriminant: D,
pub body: Option<Bytes>,
pub status: StatusCode,
}
pub trait IntoRequest: Send {
type Discriminant: Send;
type Response: for<'de> serde::Deserialize<'de> + Send;
fn into_request(self) -> ApiRequest<Self::Discriminant>;
}
pub struct FactionScope<'e, E>(&'e E)
where
E: Executor;
impl<E> FactionScope<'_, E>
where
E: Executor,
{
pub async fn chains_for_id<S>(
&self,
id: FactionId,
builder: impl FnOnce(
FactionChainsRequestBuilder<faction_chains_request_builder::Empty>,
) -> FactionChainsRequestBuilder<S>,
) -> Result<FactionChainsResponse, E::Error>
where
S: faction_chains_request_builder::IsComplete,
{
let r = builder(FactionChainsRequest::with_id(id)).build();
self.0.fetch(r).await
}
}
#[derive(Builder)]
#[builder(start_fn = with_id)]
pub struct FactionChainsRequest {
#[builder(start_fn)]
pub id: FactionId,
pub limit: Option<usize>,
}
impl IntoRequest for FactionChainsRequest {
type Discriminant = FactionId;
type Response = FactionChainsResponse;
fn into_request(self) -> ApiRequest<Self::Discriminant> {
ApiRequest {
disriminant: self.id,
path: format!("/faction/{}/chains", self.id),
parameters: self
.limit
.into_iter()
.map(|l| ("limit", l.to_string()))
.collect(),
}
}
}
#[cfg(test)]
mod test {
use crate::executor::ReqwestClient;
use super::*;
#[tokio::test]
async fn test_request() {
let client = ReqwestClient::new("nAYRXaoqzBAGalWt");
let r = models::TornItemsForIdsRequest::builder("1".to_owned()).build();
client.fetch(r).await.unwrap();
}
}

View file

@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/requests.rs"));

View file

@ -1,12 +0,0 @@
use async_trait::async_trait;
use crate::send::ApiClient;
#[async_trait]
impl ApiClient for reqwest::Client {
type Error = reqwest::Error;
async fn request(&self, url: String) -> Result<serde_json::Value, Self::Error> {
self.get(url).send().await?.json().await
}
}

686
torn-api/src/scopes.rs Normal file
View file

@ -0,0 +1,686 @@
include!(concat!(env!("OUT_DIR"), "/scopes.rs"));
#[cfg(test)]
pub(super) mod test {
use std::{collections::VecDeque, sync::OnceLock, time::Duration};
use tokio::sync::mpsc;
use crate::executor::ReqwestClient;
use super::*;
static TICKETS: OnceLock<mpsc::Sender<mpsc::Sender<ReqwestClient>>> = OnceLock::new();
pub(crate) async fn test_client() -> ReqwestClient {
let (tx, mut rx) = mpsc::channel(1);
let ticket_tx = TICKETS
.get_or_init(|| {
let (tx, mut rx) = mpsc::channel(1);
std::thread::spawn(move || {
let mut queue = VecDeque::<mpsc::Sender<ReqwestClient>>::new();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
rt.block_on(async move {
loop {
tokio::select! {
recv_result = rx.recv() => {
match recv_result {
Some(ticket) => queue.push_back(ticket),
None => break,
}
}
_ = tokio::time::sleep(Duration::from_secs(1)) => {
if let Some(next) = queue.pop_front() {
next.send(ReqwestClient::new(&std::env::var("API_KEY").unwrap())).await.unwrap()
}
}
}
}
});
});
tx
})
.clone();
ticket_tx.send(tx).await.unwrap();
rx.recv().await.unwrap()
}
#[tokio::test]
async fn faction_applications() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.applications(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_attacks() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.attacks(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_attacksfull() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.attacksfull(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_balance() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.balance(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_basic() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.basic(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_basic_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.basic_for_id(19.into(), |b| b).await.unwrap();
}
#[tokio::test]
async fn faction_chain() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.chain(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_chain_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.chain_for_id(19.into(), |b| b).await.unwrap();
}
#[tokio::test]
async fn faction_chains() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.chains(|b| b).await.unwrap();
}
#[tokio::test]
async fn factions_chains_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.chains_for_id(19.into(), |b| b).await.unwrap();
}
#[tokio::test]
async fn faction_chain_report() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.chainreport(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_chain_report_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope
.chainreport_for_chain_id(47004769.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn faction_contributors() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope
.contributors(|b| b.stat(crate::models::FactionStatEnum::Revives))
.await
.unwrap();
}
#[tokio::test]
async fn faction_crimes() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.crimes(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_crime_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope
.crime_for_id(468347.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn faction_hof() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.hof(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_hof_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.hof_for_id(19.into(), |b| b).await.unwrap();
}
#[tokio::test]
async fn faction_members() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.members(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_members_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope
.members_for_id(19.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn faction_news() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope
.news(|b| b.cat(crate::models::FactionNewsCategory::Attack))
.await
.unwrap();
}
#[tokio::test]
async fn faction_ranked_wars() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.rankedwars(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_ranked_war_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope
.rankedwars_for_id(19.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn faction_ranked_war_report_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope
.rankedwarreport_for_id(24424.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn faction_revives() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.revives(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_revives_full() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.revives_full(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_stats() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.stats(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_upgrades() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.upgrades(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_wars() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.wars(|b| b).await.unwrap();
}
#[tokio::test]
async fn faction_wars_for_id() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.wars_for_id(19.into(), |b| b).await.unwrap();
}
#[tokio::test]
async fn lookup() {
let client = test_client().await;
let faction_scope = FactionScope(&client);
faction_scope.lookup(|b| b).await.unwrap();
}
#[tokio::test]
async fn forum_categories() {
let client = test_client().await;
let forum_scope = ForumScope(&client);
forum_scope.categories(|b| b).await.unwrap();
}
#[tokio::test]
async fn forum_posts_for_thread_id() {
let client = test_client().await;
let forum_scope = ForumScope(&client);
forum_scope
.posts_for_thread_id(16129703.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn forum_thread_for_thread_id() {
let client = test_client().await;
let forum_scope = ForumScope(&client);
forum_scope
.thread_for_thread_id(16129703.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn forum_threads() {
let client = test_client().await;
let forum_scope = ForumScope(&client);
forum_scope.threads(|b| b).await.unwrap();
}
#[tokio::test]
async fn forum_threads_for_category_ids() {
let client = test_client().await;
let forum_scope = ForumScope(&client);
forum_scope
.threads_for_category_ids("2".to_owned(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn forum_lookup() {
let client = test_client().await;
let forum_scope = ForumScope(&client);
forum_scope.lookup(|b| b).await.unwrap();
}
#[tokio::test]
async fn forum_timestamp() {
let client = test_client().await;
let forum_scope = ForumScope(&client);
forum_scope.timestamp(|b| b).await.unwrap();
}
#[tokio::test]
async fn market_itemmarket_for_id() {
let client = test_client().await;
let market_scope = MarketScope(&client);
market_scope
.itemmarket_for_id(1.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn market_lookup() {
let client = test_client().await;
let market_scope = MarketScope(&client);
market_scope.lookup(|b| b).await.unwrap();
}
#[tokio::test]
async fn market_timestamp() {
let client = test_client().await;
let market_scope = MarketScope(&client);
market_scope.timestamp(|b| b).await.unwrap();
}
#[tokio::test]
async fn racing_cars() {
let client = test_client().await;
let racing_scope = RacingScope(&client);
racing_scope.cars(|b| b).await.unwrap();
}
#[tokio::test]
async fn racing_carupgrades() {
let client = test_client().await;
let racing_scope = RacingScope(&client);
racing_scope.carupgrades(|b| b).await.unwrap();
}
#[tokio::test]
async fn racing_races() {
let client = test_client().await;
let racing_scope = RacingScope(&client);
racing_scope.races(|b| b).await.unwrap();
}
#[tokio::test]
async fn racing_race_for_race_id() {
let client = test_client().await;
let racing_scope = RacingScope(&client);
racing_scope
.race_for_race_id(14650821.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn racing_tracks() {
let client = test_client().await;
let racing_scope = RacingScope(&client);
racing_scope.tracks(|b| b).await.unwrap();
}
#[tokio::test]
async fn racing_lookup() {
let client = test_client().await;
let racing_scope = RacingScope(&client);
racing_scope.lookup(|b| b).await.unwrap();
}
#[tokio::test]
async fn racing_timestamp() {
let client = test_client().await;
let racing_scope = RacingScope(&client);
racing_scope.timestamp(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_attacklog() {
let client = test_client().await;
let racing_scope = TornScope(&client);
racing_scope
.attacklog(|b| b.log("ec987a60a22155cbfb7c1625cbb2092f".to_owned()))
.await
.unwrap();
}
#[tokio::test]
async fn torn_bounties() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.bounties(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_calendar() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.calendar(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_crimes() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.crimes(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_factionhof() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope
.factionhof(|b| b.cat(crate::models::TornFactionHofCategory::Rank))
.await
.unwrap();
}
#[tokio::test]
async fn torn_factiontree() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.factiontree(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_hof() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope
.hof(|b| b.cat(crate::models::TornHofCategory::Offences))
.await
.unwrap();
}
#[tokio::test]
async fn torn_itemammo() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.itemammo(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_itemmods() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.itemmods(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_items() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.items(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_items_for_ids() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope
.items_for_ids("1".to_owned(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn torn_logcategories() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.logcategories(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_logtypes() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.logtypes(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_logtypes_for_log_category_id() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope
.logtypes_for_log_category_id(23.into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn torn_subrcimes_for_crime_id() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope
.subcrimes_for_crime_id("3".into(), |b| b)
.await
.unwrap();
}
#[tokio::test]
async fn torn_lookup() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.lookup(|b| b).await.unwrap();
}
#[tokio::test]
async fn torn_timestamp() {
let client = test_client().await;
let torn_scope = TornScope(&client);
torn_scope.timestamp(|b| b).await.unwrap();
}
}

View file

@ -1,280 +0,0 @@
use std::collections::HashMap;
use async_trait::async_trait;
use crate::{ApiClientError, ApiRequest, ApiResponse, ApiSelection, DirectExecutor};
pub struct ApiProvider<'a, C, E>
where
C: ApiClient,
E: RequestExecutor<C>,
{
client: &'a C,
executor: E,
}
impl<'a, C, E> ApiProvider<'a, C, E>
where
C: ApiClient,
E: RequestExecutor<C>,
{
pub fn new(client: &'a C, executor: E) -> ApiProvider<'a, C, E> {
Self { client, executor }
}
#[cfg(feature = "user")]
pub async fn user<F>(&self, build: F) -> Result<crate::user::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::user::Selection>,
) -> crate::ApiRequestBuilder<crate::user::Selection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
#[cfg(feature = "user")]
pub async fn users<F, L, I>(
&self,
ids: L,
build: F,
) -> HashMap<I, Result<crate::user::Response, E::Error>>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::user::Selection>,
) -> crate::ApiRequestBuilder<crate::user::Selection>,
I: ToString + std::hash::Hash + std::cmp::Eq + Send + Sync,
L: IntoIterator<Item = I>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute_many(self.client, builder.request, Vec::from_iter(ids))
.await
}
#[cfg(feature = "faction")]
pub async fn faction<F>(&self, build: F) -> Result<crate::faction::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::faction::Selection>,
) -> crate::ApiRequestBuilder<crate::faction::Selection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
#[cfg(feature = "faction")]
pub async fn factions<F, L, I>(
&self,
ids: L,
build: F,
) -> HashMap<I, Result<crate::faction::Response, E::Error>>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::faction::Selection>,
) -> crate::ApiRequestBuilder<crate::faction::Selection>,
I: ToString + std::hash::Hash + std::cmp::Eq + Send + Sync,
L: IntoIterator<Item = I>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute_many(self.client, builder.request, Vec::from_iter(ids))
.await
}
#[cfg(feature = "market")]
pub async fn market<F>(&self, build: F) -> Result<crate::market::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::market::MarketSelection>,
) -> crate::ApiRequestBuilder<crate::market::MarketSelection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
#[cfg(feature = "market")]
pub async fn markets<F, L, I>(
&self,
ids: L,
build: F,
) -> HashMap<I, Result<crate::market::Response, E::Error>>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::market::MarketSelection>,
) -> crate::ApiRequestBuilder<crate::market::MarketSelection>,
I: ToString + std::hash::Hash + std::cmp::Eq + Send + Sync,
L: IntoIterator<Item = I>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute_many(self.client, builder.request, Vec::from_iter(ids))
.await
}
#[cfg(feature = "torn")]
pub async fn torn<F>(&self, build: F) -> Result<crate::torn::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::torn::Selection>,
) -> crate::ApiRequestBuilder<crate::torn::Selection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
#[cfg(feature = "torn")]
pub async fn torns<F, L, I>(
&self,
ids: L,
build: F,
) -> HashMap<I, Result<crate::torn::Response, E::Error>>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::torn::Selection>,
) -> crate::ApiRequestBuilder<crate::torn::Selection>,
I: ToString + std::hash::Hash + std::cmp::Eq + Send + Sync,
L: IntoIterator<Item = I>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute_many(self.client, builder.request, Vec::from_iter(ids))
.await
}
#[cfg(feature = "key")]
pub async fn key<F>(&self, build: F) -> Result<crate::key::Response, E::Error>
where
F: FnOnce(
crate::ApiRequestBuilder<crate::key::Selection>,
) -> crate::ApiRequestBuilder<crate::key::Selection>,
{
let mut builder = crate::ApiRequestBuilder::default();
builder = build(builder);
self.executor
.execute(self.client, builder.request, builder.id)
.await
}
}
#[async_trait]
pub trait RequestExecutor<C>
where
C: ApiClient,
{
type Error: std::error::Error + Send + Sync;
async fn execute<A>(
&self,
client: &C,
request: ApiRequest<A>,
id: Option<String>,
) -> Result<A::Response, Self::Error>
where
A: ApiSelection;
async fn execute_many<A, I>(
&self,
client: &C,
request: ApiRequest<A>,
ids: Vec<I>,
) -> HashMap<I, Result<A::Response, Self::Error>>
where
A: ApiSelection,
I: ToString + std::hash::Hash + std::cmp::Eq + Send + Sync;
}
#[async_trait]
impl<C> RequestExecutor<C> for DirectExecutor<C>
where
C: ApiClient,
{
type Error = ApiClientError<C::Error>;
async fn execute<A>(
&self,
client: &C,
request: ApiRequest<A>,
id: Option<String>,
) -> Result<A::Response, Self::Error>
where
A: ApiSelection,
{
let url = request.url(&self.key, id.as_deref());
let value = client.request(url).await.map_err(ApiClientError::Client)?;
Ok(ApiResponse::from_value(value)?.into())
}
async fn execute_many<A, I>(
&self,
client: &C,
request: ApiRequest<A>,
ids: Vec<I>,
) -> HashMap<I, Result<A::Response, Self::Error>>
where
A: ApiSelection,
I: ToString + std::hash::Hash + std::cmp::Eq + Send + Sync,
{
let request_ref = &request;
let tuples = futures::future::join_all(ids.into_iter().map(|i| async move {
let id_string = i.to_string();
let url = request_ref.url(&self.key, Some(&id_string));
let value = client.request(url).await.map_err(ApiClientError::Client);
(
i,
value.and_then(|v| {
ApiResponse::from_value(v)
.map(Into::into)
.map_err(Into::into)
}),
)
}))
.await;
HashMap::from_iter(tuples)
}
}
#[async_trait]
pub trait ApiClient: Send + Sync {
type Error: std::error::Error + Sync + Send;
async fn request(&self, url: String) -> Result<serde_json::Value, Self::Error>;
fn torn_api<S>(&self, key: S) -> ApiProvider<Self, DirectExecutor<Self>>
where
Self: Sized,
S: ToString,
{
ApiProvider::new(self, DirectExecutor::new(key.to_string()))
}
}

View file

@ -1,417 +0,0 @@
use std::collections::{BTreeMap, HashMap};
use chrono::{DateTime, Utc};
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer,
};
use torn_api_macros::ApiCategory;
use crate::{de_util, user};
#[derive(Debug, Clone, Copy, ApiCategory)]
#[api(category = "torn")]
#[non_exhaustive]
pub enum TornSelection {
#[api(
field = "competition",
with = "decode_competition",
type = "Option<Competition>"
)]
Competition,
#[api(
type = "HashMap<String, TerritoryWar>",
with = "decode_territory_wars",
field = "territorywars"
)]
TerritoryWars,
#[api(type = "HashMap<String, Racket>", field = "rackets")]
Rackets,
#[api(
type = "HashMap<String, Territory>",
with = "decode_territory",
field = "territory"
)]
Territory,
#[api(type = "TerritoryWarReport", field = "territorywarreport")]
TerritoryWarReport,
#[api(type = "BTreeMap<i32, Item>", field = "items")]
Items,
}
pub type Selection = TornSelection;
#[derive(Debug, Clone, Deserialize)]
pub struct EliminationLeaderboard {
pub position: i16,
pub team: user::EliminationTeam,
pub score: i16,
pub lives: i16,
pub participants: Option<i16>,
pub wins: Option<i32>,
pub losses: Option<i32>,
}
#[derive(Debug, Clone)]
pub enum Competition {
Elimination { teams: Vec<EliminationLeaderboard> },
Unkown(String),
}
fn decode_territory_wars<'de, D>(deserializer: D) -> Result<HashMap<String, TerritoryWar>, D::Error>
where
D: serde::Deserializer<'de>,
{
let map: Option<_> = serde::Deserialize::deserialize(deserializer)?;
Ok(map.unwrap_or_default())
}
fn decode_competition<'de, D>(deserializer: D) -> Result<Option<Competition>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct CompetitionVisitor;
impl<'de> Visitor<'de> for CompetitionVisitor {
type Value = Option<Competition>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("struct Competition")
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_map(self)
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut name = None;
let mut teams = None;
while let Some(key) = map.next_key()? {
match key {
"name" => {
name = Some(map.next_value()?);
}
"teams" => {
teams = Some(map.next_value()?);
}
_ => (),
};
}
let name = name.ok_or_else(|| de::Error::missing_field("name"))?;
match name {
"Elimination" => Ok(Some(Competition::Elimination {
teams: teams.ok_or_else(|| de::Error::missing_field("teams"))?,
})),
"" => Ok(None),
v => Ok(Some(Competition::Unkown(v.to_owned()))),
}
}
}
deserializer.deserialize_option(CompetitionVisitor)
}
#[derive(Debug, Clone, Deserialize)]
pub struct TerritoryWar {
pub territory_war_id: i32,
pub assaulting_faction: i32,
pub defending_faction: i32,
#[serde(with = "chrono::serde::ts_seconds")]
pub started: DateTime<Utc>,
#[serde(with = "chrono::serde::ts_seconds")]
pub ends: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Racket {
pub name: String,
pub level: i16,
pub reward: String,
#[serde(with = "chrono::serde::ts_seconds")]
pub created: DateTime<Utc>,
#[serde(with = "chrono::serde::ts_seconds")]
pub changed: DateTime<Utc>,
#[serde(rename = "faction")]
pub faction_id: Option<i32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Territory {
pub sector: i16,
pub size: i16,
pub slots: i16,
pub daily_respect: i16,
pub faction: i32,
pub neighbors: Vec<String>,
pub war: Option<TerritoryWar>,
pub racket: Option<Racket>,
}
fn decode_territory<'de, D>(deserializer: D) -> Result<HashMap<String, Territory>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Clone, Debug, Deserialize)]
pub struct TerritoryWarReportTerritory {
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TerritoryWarOutcome {
EndWithPeaceTreaty,
EndWithDestroyDefense,
FailAssault,
SuccessAssault,
}
#[derive(Clone, Debug, Deserialize)]
pub struct TerritoryWarReportWar {
#[serde(with = "chrono::serde::ts_seconds")]
pub start: DateTime<Utc>,
#[serde(with = "chrono::serde::ts_seconds")]
pub end: DateTime<Utc>,
pub result: TerritoryWarOutcome,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum TerritoryWarReportRole {
Aggressor,
Defender,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TerritoryWarReportFaction {
pub name: String,
pub score: i32,
pub joins: i32,
pub clears: i32,
#[serde(rename = "type")]
pub role: TerritoryWarReportRole,
}
#[derive(Clone, Debug, Deserialize)]
pub struct TerritoryWarReport {
pub territory: TerritoryWarReportTerritory,
pub war: TerritoryWarReportWar,
pub factions: HashMap<i32, TerritoryWarReportFaction>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ItemType {
Primary,
Secondary,
Melee,
Temporary,
Defensive,
Collectible,
Medical,
Drug,
Booster,
#[serde(rename = "Energy Drink")]
EnergyDrink,
Alcohol,
Book,
Candy,
Car,
Clothing,
Electronic,
Enhancer,
Flower,
Jewelry,
Other,
Special,
#[serde(rename = "Supply Pack")]
SupplyPack,
Virus,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
//Missing hand to hand because it is not possible as a weapon
pub enum WeaponType {
Slashing,
Rifle,
SMG,
Piercing,
Clubbing,
Pistol,
#[serde(rename = "Machine gun")]
MachineGun,
Mechanical,
Temporary,
Heavy,
Shotgun,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Item<'a> {
pub name: String,
pub description: String,
#[serde(deserialize_with = "de_util::empty_string_is_none")]
pub effect: Option<&'a str>,
#[serde(deserialize_with = "de_util::empty_string_is_none")]
pub requirement: Option<&'a str>,
#[serde(rename = "type")]
pub item_type: ItemType,
pub weapon_type: Option<WeaponType>,
#[serde(deserialize_with = "de_util::zero_is_none")]
pub buy_price: Option<u64>,
#[serde(deserialize_with = "de_util::zero_is_none")]
pub sell_price: Option<u64>,
#[serde(deserialize_with = "de_util::zero_is_none")]
pub market_value: Option<u64>,
#[serde(deserialize_with = "de_util::zero_is_none")]
pub circulation: Option<u32>,
pub image: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{async_test, setup, Client, ClientTrait};
#[async_test]
async fn competition() {
let key = setup();
let response = Client::default()
.torn_api(key)
.torn(|b| {
b.selections([
TornSelection::Competition,
TornSelection::TerritoryWars,
TornSelection::Rackets,
])
})
.await
.unwrap();
response.competition().unwrap();
response.territory_wars().unwrap();
response.rackets().unwrap();
}
#[async_test]
async fn territory() {
let key = setup();
let response = Client::default()
.torn_api(key)
.torn(|b| b.selections([Selection::Territory]).id("NSC"))
.await
.unwrap();
let territory = response.territory().unwrap();
assert!(territory.contains_key("NSC"));
}
#[async_test]
async fn invalid_territory() {
let key = setup();
let response = Client::default()
.torn_api(key)
.torn(|b| b.selections([Selection::Territory]).id("AAA"))
.await
.unwrap();
assert!(response.territory().unwrap().is_empty());
}
#[async_test]
async fn territory_war_report() {
let key = setup();
let response = Client::default()
.torn_api(&key)
.torn(|b| b.selections([Selection::TerritoryWarReport]).id(37403))
.await
.unwrap();
assert_eq!(
response.territory_war_report().unwrap().war.result,
TerritoryWarOutcome::SuccessAssault
);
let response = Client::default()
.torn_api(&key)
.torn(|b| b.selections([Selection::TerritoryWarReport]).id(37502))
.await
.unwrap();
assert_eq!(
response.territory_war_report().unwrap().war.result,
TerritoryWarOutcome::FailAssault
);
let response = Client::default()
.torn_api(&key)
.torn(|b| b.selections([Selection::TerritoryWarReport]).id(37860))
.await
.unwrap();
assert_eq!(
response.territory_war_report().unwrap().war.result,
TerritoryWarOutcome::EndWithPeaceTreaty
);
let response = Client::default()
.torn_api(&key)
.torn(|b| b.selections([Selection::TerritoryWarReport]).id(23757))
.await
.unwrap();
assert_eq!(
response.territory_war_report().unwrap().war.result,
TerritoryWarOutcome::EndWithDestroyDefense
);
}
#[async_test]
async fn item() {
let key = setup();
let response = Client::default()
.torn_api(key)
.torn(|b| b.selections([Selection::Items]).id(837))
.await
.unwrap();
let item_list = response.items().unwrap();
assert!(item_list.contains_key(&837));
}
}

View file

@ -1,828 +0,0 @@
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer,
};
use std::{
collections::{BTreeMap, HashMap},
iter::zip,
};
use torn_api_macros::{ApiCategory, IntoOwned};
use crate::de_util;
pub use crate::common::{Attack, AttackFull, LastAction, Status};
#[derive(Debug, Clone, Copy, ApiCategory)]
#[api(category = "user")]
#[non_exhaustive]
pub enum UserSelection {
#[api(type = "Basic", flatten)]
Basic,
#[api(type = "Profile", flatten)]
Profile,
#[api(type = "Discord", field = "discord")]
Discord,
#[api(type = "PersonalStats", field = "personalstats")]
PersonalStats,
#[api(type = "CriminalRecord", field = "criminalrecord")]
Crimes,
#[api(type = "BTreeMap<i32, Attack>", field = "attacks")]
AttacksFull,
#[api(type = "BTreeMap<i32, AttackFull>", field = "attacks")]
Attacks,
#[api(type = "HashMap<Icon, &str>", field = "icons")]
Icons,
#[api(type = "Awards<Medals>", flatten)]
Medals,
#[api(type = "Awards<Honors>", flatten)]
Honors,
}
pub type Selection = UserSelection;
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash)]
pub enum Gender {
Male,
Female,
Enby,
}
#[derive(Debug, IntoOwned)]
pub struct Faction<'a> {
pub faction_id: i32,
pub faction_name: &'a str,
pub days_in_faction: i16,
pub position: &'a str,
pub faction_tag: Option<&'a str>,
pub faction_tag_image: Option<&'a str>,
}
fn deserialize_faction<'de, D>(deserializer: D) -> Result<Option<Faction<'de>>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum Field {
FactionId,
FactionName,
DaysInFaction,
Position,
FactionTag,
FactionTagImage,
}
struct FactionVisitor;
impl<'de> Visitor<'de> for FactionVisitor {
type Value = Option<Faction<'de>>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("struct Faction")
}
fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut faction_id = None;
let mut faction_name = None;
let mut days_in_faction = None;
let mut position = None;
let mut faction_tag = None;
let mut faction_tag_image = None;
while let Some(key) = map.next_key()? {
match key {
Field::FactionId => {
faction_id = Some(map.next_value()?);
}
Field::FactionName => {
faction_name = Some(map.next_value()?);
}
Field::DaysInFaction => {
days_in_faction = Some(map.next_value()?);
}
Field::Position => {
position = Some(map.next_value()?);
}
Field::FactionTag => {
faction_tag = map.next_value()?;
}
Field::FactionTagImage => {
faction_tag_image = map.next_value()?;
}
}
}
let faction_id = faction_id.ok_or_else(|| de::Error::missing_field("faction_id"))?;
let faction_name =
faction_name.ok_or_else(|| de::Error::missing_field("faction_name"))?;
let days_in_faction =
days_in_faction.ok_or_else(|| de::Error::missing_field("days_in_faction"))?;
let position = position.ok_or_else(|| de::Error::missing_field("position"))?;
if faction_id == 0 {
Ok(None)
} else {
Ok(Some(Faction {
faction_id,
faction_name,
days_in_faction,
position,
faction_tag,
faction_tag_image,
}))
}
}
}
const FIELDS: &[&str] = &[
"faction_id",
"faction_name",
"days_in_faction",
"position",
"faction_tag",
];
deserializer.deserialize_struct("Faction", FIELDS, FactionVisitor)
}
#[derive(Debug, IntoOwned, Deserialize)]
pub struct Basic<'a> {
pub player_id: i32,
pub name: &'a str,
pub level: i16,
pub gender: Gender,
pub status: Status<'a>,
}
#[derive(Debug, Clone, IntoOwned, PartialEq, Eq, Deserialize)]
#[into_owned(identity)]
pub struct Discord {
#[serde(
rename = "userID",
deserialize_with = "de_util::empty_string_int_option"
)]
pub user_id: Option<i32>,
#[serde(rename = "discordID", deserialize_with = "de_util::string_is_long")]
pub discord_id: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LifeBar {
pub current: i16,
pub maximum: i16,
pub increment: i16,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EliminationTeam2022 {
Firestarters,
HardBoiled,
QuackAddicts,
RainMen,
TotallyBoned,
RawringThunder,
DirtyCops,
LaughingStock,
JeanTherapy,
#[serde(rename = "satants-soldiers")]
SatansSoldiers,
WolfPack,
Sleepyheads,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EliminationTeam {
Backstabbers,
Cheese,
DeathsDoor,
RegularHumanPeople,
FlowerRangers,
ReligiousExtremists,
Hivemind,
CapsLockCrew,
}
#[derive(Debug, Clone, IntoOwned)]
#[into_owned(identity)]
pub enum Competition {
Elimination {
score: i32,
attacks: i16,
team: EliminationTeam,
},
DogTags {
score: i32,
position: Option<i32>,
},
Unknown,
}
fn deserialize_comp<'de, D>(deserializer: D) -> Result<Option<Competition>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
enum Field {
Name,
Score,
Team,
Attacks,
TeamName,
Position,
#[serde(other)]
Ignore,
}
#[derive(Deserialize)]
enum CompetitionName {
Elimination,
#[serde(rename = "Dog Tags")]
DogTags,
#[serde(other)]
Unknown,
}
struct CompetitionVisitor;
impl<'de> Visitor<'de> for CompetitionVisitor {
type Value = Option<Competition>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("struct Competition")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_map(self)
}
fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut team = None;
let mut score = None;
let mut attacks = None;
let mut name = None;
let mut position = None;
while let Some(key) = map.next_key()? {
match key {
Field::Name => {
name = Some(map.next_value()?);
}
Field::Score => {
score = Some(map.next_value()?);
}
Field::Attacks => {
attacks = Some(map.next_value()?);
}
Field::Position => {
position = Some(map.next_value()?);
}
Field::Team => {
let team_raw: &str = map.next_value()?;
team = if team_raw.is_empty() {
None
} else {
Some(match team_raw {
"backstabbers" => EliminationTeam::Backstabbers,
"cheese" => EliminationTeam::Cheese,
"deaths-door" => EliminationTeam::DeathsDoor,
"regular-human-people" => EliminationTeam::RegularHumanPeople,
"flower-rangers" => EliminationTeam::FlowerRangers,
"religious-extremists" => EliminationTeam::ReligiousExtremists,
"hivemind" => EliminationTeam::Hivemind,
"caps-lock-crew" => EliminationTeam::CapsLockCrew,
_ => Err(de::Error::unknown_variant(team_raw, &[]))?,
})
}
}
_ => (),
}
}
let name = name.ok_or_else(|| de::Error::missing_field("name"))?;
match name {
CompetitionName::Elimination => {
if let Some(team) = team {
let score = score.ok_or_else(|| de::Error::missing_field("score"))?;
let attacks = attacks.ok_or_else(|| de::Error::missing_field("attacks"))?;
Ok(Some(Competition::Elimination {
team,
score,
attacks,
}))
} else {
Ok(None)
}
}
CompetitionName::DogTags => {
let score = score.ok_or_else(|| de::Error::missing_field("score"))?;
let position = position.ok_or_else(|| de::Error::missing_field("position"))?;
Ok(Some(Competition::DogTags { score, position }))
}
CompetitionName::Unknown => Ok(Some(Competition::Unknown)),
}
}
}
deserializer.deserialize_option(CompetitionVisitor)
}
#[derive(Debug, IntoOwned, Deserialize)]
pub struct Profile<'a> {
pub player_id: i32,
pub name: &'a str,
pub rank: &'a str,
pub level: i16,
pub gender: Gender,
pub age: i32,
pub life: LifeBar,
pub last_action: LastAction,
#[serde(deserialize_with = "deserialize_faction")]
pub faction: Option<Faction<'a>>,
pub job: EmploymentStatus,
pub status: Status<'a>,
#[serde(deserialize_with = "deserialize_comp")]
pub competition: Option<Competition>,
#[serde(deserialize_with = "de_util::int_is_bool")]
pub revivable: bool,
}
#[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,
}
#[derive(Deserialize)]
pub struct Crimes1 {
pub selling_illegal_products: i32,
pub theft: i32,
pub auto_theft: i32,
pub drug_deals: i32,
pub computer_crimes: i32,
pub murder: i32,
pub fraud_crimes: i32,
pub other: i32,
pub total: i32,
}
#[derive(Deserialize)]
pub struct Crimes2 {
pub vandalism: i32,
pub theft: i32,
pub counterfeiting: i32,
pub fraud: i32,
#[serde(rename = "illicitservices")]
pub illicit_services: i32,
#[serde(rename = "cybercrime")]
pub cyber_crime: i32,
pub extortion: i32,
#[serde(rename = "illegalproduction")]
pub illegal_production: i32,
pub total: i32,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum CriminalRecord {
Crimes1(Crimes1),
Crimes2(Crimes2),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Icon(i16);
impl Icon {
pub const SUBSCRIBER: Self = Self(4);
pub const LEVEL_100: Self = Self(5);
pub const GENDER_MALE: Self = Self(6);
pub const GENDER_FEMALE: Self = Self(7);
pub const MARITAL_STATUS: Self = Self(8);
pub const FACTION_MEMBER: Self = Self(9);
pub const PLAYER_COMMITTEE: Self = Self(10);
pub const STAFF: Self = Self(11);
pub const COMPANY: Self = Self(27);
pub const BANK_INVESTMENT: Self = Self(29);
pub const PROPERTY_VAULT: Self = Self(32);
pub const DUKE_LOAN: Self = Self(33);
pub const DRUG_COOLDOWN: Self = Self(53);
pub const FEDDED: Self = Self(70);
pub const TRAVELLING: Self = Self(71);
pub const FACTION_LEADER: Self = Self(74);
pub const TERRITORY_WAR: Self = Self(75);
pub const FACTION_RECRUIT: Self = Self(81);
pub const STOCK_MARKET: Self = Self(84);
}
impl<'de> Deserialize<'de> for Icon {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct IconVisitor;
impl<'de> Visitor<'de> for IconVisitor {
type Value = Icon;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "struct Icon")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
if let Some(suffix) = v.strip_prefix("icon") {
Ok(Icon(suffix.parse().map_err(|_e| {
de::Error::invalid_value(de::Unexpected::Str(suffix), &"&str \"IconXX\"")
})?))
} else {
Err(de::Error::invalid_value(
de::Unexpected::Str(v),
&"&str \"iconXX\"",
))
}
}
}
deserializer.deserialize_str(IconVisitor)
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Job {
Director,
Employee,
Education,
Army,
Law,
Casino,
Medical,
Grocer,
#[serde(other)]
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Company {
PlayerRun {
name: String,
id: i32,
company_type: u8,
},
CityJob,
}
impl<'de> Deserialize<'de> for Company {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct CompanyVisitor;
impl<'de> Visitor<'de> for CompanyVisitor {
type Value = Company;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("enum Company")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
#[allow(clippy::enum_variant_names)]
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum Field {
CompanyId,
CompanyName,
CompanyType,
#[serde(other)]
Other,
}
let mut id = None;
let mut name = None;
let mut company_type = None;
while let Some(key) = map.next_key()? {
match key {
Field::CompanyId => {
id = Some(map.next_value()?);
if id == Some(0) {
return Ok(Company::CityJob);
}
}
Field::CompanyType => company_type = Some(map.next_value()?),
Field::CompanyName => {
name = Some(map.next_value()?);
}
Field::Other => (),
}
}
let id = id.ok_or_else(|| de::Error::missing_field("company_id"))?;
let name = name.ok_or_else(|| de::Error::missing_field("company_name"))?;
let company_type =
company_type.ok_or_else(|| de::Error::missing_field("company_type"))?;
Ok(Company::PlayerRun {
name,
id,
company_type,
})
}
}
deserializer.deserialize_map(CompanyVisitor)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EmploymentStatus {
pub job: Job,
#[serde(flatten)]
pub company: Company,
}
#[derive(Debug, Clone, Copy)]
pub struct Award {
pub award_time: chrono::DateTime<chrono::Utc>,
}
pub trait AwardMarker {
fn award_key() -> &'static str;
fn time_key() -> &'static str;
}
#[derive(Debug, Clone)]
pub struct Awards<T>
where
T: AwardMarker,
{
pub inner: BTreeMap<i32, Award>,
marker: std::marker::PhantomData<T>,
}
impl<T> std::ops::Deref for Awards<T>
where
T: AwardMarker,
{
type Target = BTreeMap<i32, Award>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T> std::ops::DerefMut for Awards<T>
where
T: AwardMarker,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
impl<T> Awards<T>
where
T: AwardMarker,
{
pub fn into_inner(self) -> BTreeMap<i32, Award> {
self.inner
}
}
pub struct Medals;
impl AwardMarker for Medals {
#[inline(always)]
fn award_key() -> &'static str {
"medals_awarded"
}
#[inline(always)]
fn time_key() -> &'static str {
"medals_time"
}
}
pub struct Honors;
impl AwardMarker for Honors {
#[inline(always)]
fn award_key() -> &'static str {
"honors_awarded"
}
#[inline(always)]
fn time_key() -> &'static str {
"honors_time"
}
}
impl<'de, T> Deserialize<'de> for Awards<T>
where
T: AwardMarker,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct AwardVisitor<T>(std::marker::PhantomData<T>)
where
T: AwardMarker;
impl<'de, T> Visitor<'de> for AwardVisitor<T>
where
T: AwardMarker,
{
type Value = Awards<T>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("struct awards")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut awards: Option<Vec<i32>> = None;
let mut times: Option<Vec<_>> = None;
while let Some(key) = map.next_key::<&'de str>()? {
if key == T::award_key() {
awards = map.next_value()?;
} else if key == T::time_key() {
times = map.next_value()?;
}
}
let awards = awards.ok_or_else(|| de::Error::missing_field(T::award_key()))?;
let times = times.ok_or_else(|| de::Error::missing_field(T::time_key()))?;
Ok(Awards {
inner: zip(
awards,
times.into_iter().map(|t| Award {
award_time: chrono::DateTime::from_timestamp(t, 0).unwrap_or_default(),
}),
)
.collect(),
marker: Default::default(),
})
}
}
deserializer.deserialize_map(AwardVisitor(Default::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{async_test, setup, Client, ClientTrait};
#[async_test]
async fn user() {
let key = setup();
let response = Client::default()
.torn_api(key)
.user(|b| {
b.selections([
Selection::Basic,
Selection::Discord,
Selection::Profile,
Selection::PersonalStats,
Selection::Crimes,
Selection::Attacks,
Selection::Medals,
Selection::Honors,
])
})
.await
.unwrap();
response.basic().unwrap();
response.discord().unwrap();
response.profile().unwrap();
response.personal_stats().unwrap();
response.crimes().unwrap();
response.attacks().unwrap();
response.attacks_full().unwrap();
response.medals().unwrap();
response.honors().unwrap();
}
#[async_test]
async fn not_in_faction() {
let key = setup();
let response = Client::default()
.torn_api(key)
.user(|b| b.id(28).selections([Selection::Profile]))
.await
.unwrap();
let faction = response.profile().unwrap().faction;
assert!(faction.is_none());
}
#[async_test]
async fn bulk() {
let key = setup();
let response = Client::default()
.torn_api(key)
.users([1, 2111649, 374272176892674048i64], |b| {
b.selections([Selection::Basic])
})
.await;
response.get(&1).as_ref().unwrap().as_ref().unwrap();
response.get(&2111649).as_ref().unwrap().as_ref().unwrap();
}
#[async_test]
async fn discord() {
let key = setup();
let response = Client::default()
.torn_api(key)
.user(|b| b.id(374272176892674048i64).selections([Selection::Basic]))
.await
.unwrap();
assert_eq!(response.basic().unwrap().player_id, 2111649);
}
#[async_test]
async fn fedded() {
let key = setup();
let response = Client::default()
.torn_api(key)
.user(|b| b.id(1900654).selections([Selection::Icons]))
.await
.unwrap();
let icons = response.icons().unwrap();
assert!(icons.contains_key(&Icon::FEDDED))
}
}