diff --git a/Cargo.toml b/Cargo.toml index b8cb29b..d9f3bea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "topgg" -version = "1.0.4" +version = "1.1.0" edition = "2021" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] description = "The official Rust wrapper for the Top.gg API" diff --git a/README.md b/README.md index 2603830..b77f4a0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# topgg [![crates.io][crates-io-image]][crates-io-url] [![crates.io downloads][crates-io-downloads-image]][crates-io-url] [![license][github-license-image]][github-license-url] [![BLAZINGLY FAST!!!][blazingly-fast-image]][blazingly-fast-url] +# [topgg](https://crates.io/crates/topgg) [![crates.io][crates-io-image]][crates-io-url] [![crates.io downloads][crates-io-downloads-image]][crates-io-url] [![license][github-license-image]][github-license-url] [![BLAZINGLY FAST!!!][blazingly-fast-image]][blazingly-fast-url] [crates-io-image]: https://img.shields.io/crates/v/topgg?style=flat-square [crates-io-downloads-image]: https://img.shields.io/crates/d/topgg?style=flat-square @@ -14,7 +14,7 @@ The official Rust SDK for the [Top.gg API](https://docs.top.gg). Make sure to have a [Top.gg](https://top.gg) API token handy, you can have an API token if you own a listed Discord bot on [Top.gg](https://top.gg) (open the edit page, see in `Webhooks` section) then add the following to your `Cargo.toml`'s dependencies: ```toml -topgg = "1" +topgg = "1.1" ``` ## Features @@ -31,7 +31,7 @@ This library provides several feature flags that can be enabled/disabled in `Car ## Examples -More things can be read on the [documentation](https://docs.rs/topgg). +More things can be read in the [documentation](https://docs.rs/topgg).
api: Fetching a single Discord user from it's Discord ID @@ -47,7 +47,6 @@ async fn main() { let user = client.get_user(661200758510977084u64).await.unwrap(); assert_eq!(user.username, "null"); - assert_eq!(user.discriminator, "8626"); assert_eq!(user.id, 661200758510977084u64); println!("{:?}", user); @@ -69,7 +68,6 @@ async fn main() { let bot = client.get_bot(264811613708746752u64).await.unwrap(); assert_eq!(bot.username, "Luca"); - assert_eq!(bot.discriminator, "1375"); assert_eq!(bot.id, 264811613708746752u64); println!("{:?}", bot); @@ -99,8 +97,8 @@ async fn main() { .certified(true); let query = Query::new() - .limit(250) - .skip(50) + .limit(250u16) + .skip(50u16) .filter(filter); for bot in client.get_bots(query).await.unwrap() { @@ -156,7 +154,7 @@ In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1", features = ["autoposter"] } +topgg = { version = "1.1", features = ["autoposter"] } ``` In your code: @@ -188,7 +186,7 @@ In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1", default-features = false, features = ["actix"] } +topgg = { version = "1.1", default-features = false, features = ["actix"] } ``` In your code: @@ -224,7 +222,7 @@ In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1", default-features = false, features = ["axum"] } +topgg = { version = "1.1", default-features = false, features = ["axum"] } ``` In your code: @@ -267,7 +265,7 @@ In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1", default-features = false, features = ["rocket"] } +topgg = { version = "1.1", default-features = false, features = ["rocket"] } ``` In your code: @@ -303,7 +301,7 @@ In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1", default-features = false, features = ["warp"] } +topgg = { version = "1.1", default-features = false, features = ["warp"] } ``` In your code: diff --git a/src/autoposter.rs b/src/autoposter.rs index 62a0143..82fc784 100644 --- a/src/autoposter.rs +++ b/src/autoposter.rs @@ -6,21 +6,22 @@ use tokio::{ time::{sleep, Duration}, }; -/// A struct that lets you automate the process of posting bot statistics to the [Top.gg](https://top.gg) API. +/// A struct that lets you automate the process of posting bot statistics to the [Top.gg](https://top.gg) API in intervals. +#[must_use] pub struct Autoposter { thread: JoinHandle<()>, data: Arc>>, } impl Autoposter { - pub(crate) fn new(client: Arc, delay: u64) -> Self { + pub(crate) fn new(client: Arc, interval: u64) -> Self { let current_thread_data = Arc::new(Mutex::new(None)); let thread_data = Arc::clone(¤t_thread_data); Self { thread: spawn(async move { loop { - sleep(Duration::from_secs(delay)).await; + sleep(Duration::from_secs(interval)).await; let lock = thread_data.lock().await; @@ -33,7 +34,7 @@ impl Autoposter { } } - /// Feeds new bot stats to the autoposter. The autoposter will automatically post it to the [Top.gg](https://top.gg) servers once the delay is complete. + /// Feeds new bot stats to the autoposter. The autoposter will automatically post it to the [Top.gg](https://top.gg) servers in intervals. /// /// # Examples /// diff --git a/src/bot.rs b/src/bot.rs index c9c8075..541fb96 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,13 +1,14 @@ use crate::{snowflake, util}; use chrono::{offset::Utc, DateTime}; -use core::cmp::min; -use serde::{ - de::{self, Deserializer}, - Deserialize, Serialize, +use core::{ + cmp::min, + fmt::{self, Debug, Formatter}, }; +use serde::{Deserialize, Deserializer, Serialize}; /// A struct representing a Discord Bot listed on [Top.gg](https://top.gg). -#[derive(Clone, Debug, Deserialize)] +#[must_use] +#[derive(Clone, Deserialize)] pub struct Bot { /// The ID of this Discord bot. #[serde(deserialize_with = "snowflake::deserialize")] @@ -16,7 +17,7 @@ pub struct Bot { /// The username of this Discord bot. pub username: String, - /// The discriminator of this Discord bot. + #[deprecated(since = "1.1.0")] pub discriminator: String, /// The prefix of this Discord bot. @@ -27,16 +28,23 @@ pub struct Bot { pub short_description: String, /// The long description of this Discord bot. It can contain HTML and/or Markdown. - #[serde(rename = "longdesc")] + #[serde( + default, + deserialize_with = "util::deserialize_optional_string", + rename = "longdesc" + )] pub long_description: Option, /// The tags of this Discord bot. + #[serde(default, deserialize_with = "util::deserialize_default")] pub tags: Vec, /// The website URL of this Discord bot. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub website: Option, /// The link to this Discord bot's GitHub repository. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub github: Option, /// A list of IDs of this Discord bot's owners. The main owner is the first ID in the array. @@ -44,14 +52,15 @@ pub struct Bot { pub owners: Vec, /// A list of IDs of the guilds featured on this Discord bot's page. - #[serde(deserialize_with = "snowflake::deserialize_vec")] + #[serde(default, deserialize_with = "snowflake::deserialize_vec")] pub guilds: Vec, - /// The custom bot invite URL of this Discord bot. - pub invite: Option, - /// The URL for this Discord bot's banner image. - #[serde(rename = "bannerUrl")] + #[serde( + default, + deserialize_with = "util::deserialize_optional_string", + rename = "bannerUrl" + )] pub banner_url: Option, /// The date when this Discord bot was approved on [Top.gg](https://top.gg). @@ -62,10 +71,8 @@ pub struct Bot { pub is_certified: bool, /// A list of this Discord bot's shards. - pub shards: Option>, - - /// The amount of shards this Discord bot has according to posted stats. - pub shard_count: Option, + #[serde(default, deserialize_with = "util::deserialize_default")] + pub shards: Vec, /// The amount of upvotes this Discord bot has. #[serde(rename = "points")] @@ -79,19 +86,29 @@ pub struct Bot { #[serde(default, deserialize_with = "deserialize_support_server")] pub support: Option, + #[serde(default, deserialize_with = "util::deserialize_optional_string")] avatar: Option, + + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + invite: Option, + + shard_count: Option, + + #[serde(default, deserialize_with = "util::deserialize_optional_string")] vanity: Option, } +#[inline(always)] pub(crate) fn deserialize_support_server<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { - let s: Option<&str> = de::Deserialize::deserialize(deserializer)?; - - Ok(s.map(|support| format!("https://discord.com/invite/{support}"))) + Ok( + unsafe { util::deserialize_optional_string(deserializer).unwrap_unchecked() } + .map(|support| format!("https://discord.com/invite/{support}")), + ) } impl Bot { @@ -119,7 +136,62 @@ impl Bot { #[must_use] #[inline(always)] pub fn avatar(&self) -> String { - util::get_avatar(&self.avatar, &self.discriminator, self.id) + util::get_avatar(&self.avatar, self.id) + } + + /// The invite URL of this Discord bot. + /// + /// # Examples + /// + /// Basic usage: + /// + /// ```rust,no_run + /// use topgg::Client; + /// + /// #[tokio::main] + /// async fn main() { + /// let token = env!("TOPGG_TOKEN").to_owned(); + /// let client = Client::new(token); + /// + /// let bot = client.get_bot(264811613708746752u64).await.unwrap(); + /// + /// println!("{}", bot.invite()); + /// } + /// ``` + #[must_use] + pub fn invite(&self) -> String { + match self.invite.as_ref() { + Some(inv) => inv.to_owned(), + _ => format!( + "https://discord.com/oauth2/authorize?scope=bot&client_id={}", + self.id + ), + } + } + + /// The amount of shards this Discord bot has according to posted stats. + /// + /// # Examples + /// + /// Basic usage: + /// + /// ```rust,no_run + /// use topgg::Client; + /// + /// #[tokio::main] + /// async fn main() { + /// let token = env!("TOPGG_TOKEN").to_owned(); + /// let client = Client::new(token); + /// + /// let bot = client.get_bot(264811613708746752u64).await.unwrap(); + /// + /// println!("{}", bot.shard_count()); + /// } + /// ``` + #[must_use] + #[inline(always)] + pub fn shard_count(&self) -> u64 { + self.shard_count.unwrap_or(self.shards.len() as _) } /// Retrieves the URL of this Discord bot's [Top.gg](https://top.gg) page. @@ -151,25 +223,120 @@ impl Bot { } } +impl Debug for Bot { + fn fmt(&self, fmt: &mut Formatter) -> fmt::Result { + fmt + .debug_struct("Bot") + .field("id", &self.id) + .field("username", &self.username) + .field("prefix", &self.prefix) + .field("short_description", &self.short_description) + .field("long_description", &self.long_description) + .field("tags", &self.tags) + .field("website", &self.website) + .field("github", &self.github) + .field("owners", &self.owners) + .field("guilds", &self.guilds) + .field("banner_url", &self.banner_url) + .field("date", &self.date) + .field("is_certified", &self.is_certified) + .field("shards", &self.shards) + .field("votes", &self.votes) + .field("monthly_votes", &self.monthly_votes) + .field("support", &self.support) + .field("avatar", &self.avatar()) + .field("invite", &self.invite()) + .field("shard_count", &self.shard_count()) + .field("url", &self.url()) + .finish() + } +} + #[derive(Deserialize)] pub(crate) struct Bots { pub(crate) results: Vec, } /// A struct representing a Discord bot's statistics returned from the API. -#[derive(Clone, Debug, Deserialize)] +#[must_use] +#[derive(Clone, Deserialize)] pub struct Stats { - /// The bot's server count. - pub server_count: Option, - /// The bot's server count per shard. - pub shards: Option>, + #[serde(default, deserialize_with = "util::deserialize_default")] + pub shards: Vec, + + shard_count: Option, + server_count: Option, +} + +impl Stats { + /// The amount of shards this Discord bot has according to posted stats. + /// + /// # Examples + /// + /// Basic usage: + /// + /// ```rust,no_run + /// use topgg::Client; + /// + /// #[tokio::main] + /// async fn main() { + /// let token = env!("TOPGG_TOKEN").to_owned(); + /// let client = Client::new(token); + /// + /// let stats = client.get_stats().await.unwrap(); + /// + /// println!("{:?}", stats.shard_count()); + /// } + /// ``` + #[must_use] + #[inline(always)] + pub fn shard_count(&self) -> u64 { + self.shard_count.unwrap_or(self.shards.len() as _) + } - /// The bot's shard count. - pub shard_count: Option, + /// The amount of servers this bot is in. `None` if such information is publicly unavailable. + /// + /// # Examples + /// + /// Basic usage: + /// + /// ```rust,no_run + /// use topgg::Client; + /// + /// #[tokio::main] + /// async fn main() { + /// let token = env!("TOPGG_TOKEN").to_owned(); + /// let client = Client::new(token); + /// + /// let stats = client.get_stats().await.unwrap(); + /// + /// println!("{:?}", stats.server_count()); + /// } + /// ``` + #[must_use] + pub fn server_count(&self) -> Option { + self.server_count.or(if self.shards.is_empty() { + None + } else { + Some(self.shards.iter().copied().sum()) + }) + } +} + +impl Debug for Stats { + fn fmt(&self, fmt: &mut Formatter) -> fmt::Result { + fmt + .debug_struct("Stats") + .field("shards", &self.shards) + .field("shard_count", &self.shard_count()) + .field("server_count", &self.server_count()) + .finish() + } } /// A struct representing a Discord bot's statistics [to be posted][crate::Client::post_stats] to the API. +#[must_use] #[derive(Serialize)] pub struct NewStats { server_count: u64, @@ -190,7 +357,6 @@ impl NewStats { /// /// let _stats = NewStats::count_based(12345, Some(10)); /// ``` - #[must_use] #[inline(always)] pub fn count_based(server_count: A, shard_count: Option) -> Self where @@ -221,7 +387,6 @@ impl NewStats { /// // The shard posting this data has 456 servers. /// let _stats = NewStats::shards_based([123, 456, 789], Some(1)); /// ``` - #[must_use] pub fn shards_based(shards: A, shard_index: Option) -> Self where A: IntoIterator, @@ -263,6 +428,8 @@ pub(crate) struct IsWeekend { } /// A struct for filtering the query in [`get_bots`][crate::Client::get_bots]. +#[must_use] +#[derive(Clone)] pub struct Filter(String); impl Filter { @@ -277,7 +444,6 @@ impl Filter { /// /// let _filter = Filter::new(); /// ``` - #[must_use] #[inline(always)] pub fn new() -> Self { Self(String::new()) @@ -295,37 +461,26 @@ impl Filter { /// let _filter = Filter::new() /// .username("shiro"); /// ``` - #[must_use] pub fn username(mut self, new_username: &U) -> Self where U: AsRef + ?Sized, { - self - .0 - .push_str(&format!("username: {} ", new_username.as_ref())); + self.0.push_str(&format!( + "username%3A%20{}%20", + urlencoding::encode(new_username.as_ref()) + )); self } - /// Filters only Discord bots that matches this discriminator. - /// - /// # Examples - /// - /// Basic usage: - /// - /// ```rust,no_run - /// use topgg::Filter; - /// - /// let _filter = Filter::new() - /// .discriminator("1536"); - /// ``` - #[must_use] + #[deprecated(since = "1.1.0")] pub fn discriminator(mut self, new_discriminator: &D) -> Self where D: AsRef + ?Sized, { - self - .0 - .push_str(&format!("discriminator: {} ", new_discriminator.as_ref())); + self.0.push_str(&format!( + "discriminator%3A%20{}%20", + new_discriminator.as_ref() + )); self } @@ -341,14 +496,14 @@ impl Filter { /// let _filter = Filter::new() /// .prefix("!"); /// ``` - #[must_use] pub fn prefix

(mut self, new_prefix: &P) -> Self where P: AsRef + ?Sized, { - self - .0 - .push_str(&format!("prefix: {} ", new_prefix.as_ref())); + self.0.push_str(&format!( + "prefix%3A%20{}%20", + urlencoding::encode(new_prefix.as_ref()) + )); self } @@ -364,12 +519,13 @@ impl Filter { /// let _filter = Filter::new() /// .votes(1000); /// ``` - #[must_use] pub fn votes(mut self, new_votes: V) -> Self where V: Into, { - self.0.push_str(&format!("points: {} ", new_votes.into())); + self + .0 + .push_str(&format!("points%3A%20{}%20", new_votes.into())); self } @@ -385,14 +541,14 @@ impl Filter { /// let _filter = Filter::new() /// .monthly_votes(100); /// ``` - #[must_use] pub fn monthly_votes(mut self, new_monthly_votes: M) -> Self where M: Into, { - self - .0 - .push_str(&format!("monthlyPoints: {} ", new_monthly_votes.into())); + self.0.push_str(&format!( + "monthlyPoints%3A%20{}%20", + new_monthly_votes.into() + )); self } @@ -408,14 +564,13 @@ impl Filter { /// let _filter = Filter::new() /// .certified(true); /// ``` - #[must_use] pub fn certified(mut self, is_certified: C) -> Self where C: Into, { self .0 - .push_str(&format!("certifiedBot: {} ", is_certified.into())); + .push_str(&format!("certifiedBot%3A%20{}%20", is_certified.into())); self } @@ -431,14 +586,14 @@ impl Filter { /// let _filter = Filter::new() /// .vanity("mee6"); /// ``` - #[must_use] pub fn vanity(mut self, new_vanity: &V) -> Self where V: AsRef + ?Sized, { - self - .0 - .push_str(&format!("vanity: {} ", new_vanity.as_ref())); + self.0.push_str(&format!( + "vanity%3A%20{}%20", + urlencoding::encode(new_vanity.as_ref()) + )); self } } @@ -462,6 +617,8 @@ impl Default for Filter { } /// A struct for configuring the query in [`get_bots`][crate::Client::get_bots]. +#[must_use] +#[derive(Clone)] pub struct Query(String); impl Query { @@ -476,7 +633,6 @@ impl Query { /// /// let _query = Query::new(); /// ``` - #[must_use] #[inline(always)] pub fn new() -> Self { Self(String::from("?")) @@ -492,9 +648,8 @@ impl Query { /// use topgg::Query; /// /// let _query = Query::new() - /// .limit(250); + /// .limit(250u16); /// ``` - #[must_use] pub fn limit(mut self, new_limit: N) -> Self where N: Into, @@ -515,10 +670,9 @@ impl Query { /// use topgg::Query; /// /// let _query = Query::new() - /// .limit(250) - /// .skip(100); + /// .limit(250u16) + /// .skip(100u16); /// ``` - #[must_use] pub fn skip(mut self, skip_by: S) -> Self where S: Into, @@ -543,16 +697,14 @@ impl Query { /// .certified(true); /// /// let _query = Query::new() - /// .limit(250) - /// .skip(100) + /// .limit(250u16) + /// .skip(100u16) /// .filter(filter); /// ``` - #[must_use] - pub fn filter(mut self, mut new_filter: Filter) -> Self { - new_filter.0.pop(); + pub fn filter(mut self, new_filter: Filter) -> Self { self .0 - .push_str(&format!("search={}&", urlencoding::encode(&new_filter.0))); + .push_str(&format!("search={}&", new_filter.into_query_string())); self } } @@ -581,8 +733,13 @@ impl QueryLike for Query { impl QueryLike for Filter { #[inline(always)] fn into_query_string(mut self) -> String { - self.0.pop(); - format!("?search={}", urlencoding::encode(&self.0)) + if self.0.is_empty() { + String::new() + } else { + self.0.truncate(self.0.len() - 3); + + format!("?search={}", self.0) + } } } diff --git a/src/client.rs b/src/client.rs index d39b3f0..9784a7b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -17,6 +17,7 @@ cfg_if::cfg_if! { } } +#[derive(Clone)] pub(crate) struct InnerClient { http: Http, } @@ -36,6 +37,8 @@ impl InnerClient { } /// A struct representing a [Top.gg](https://top.gg) API client instance. +#[must_use] +#[derive(Clone)] pub struct Client { inner: SyncedClient, } @@ -58,7 +61,6 @@ impl Client { /// let _client = Client::new(token); /// } /// ``` - #[must_use] #[inline(always)] pub fn new(token: String) -> Self { let inner = InnerClient { @@ -102,7 +104,6 @@ impl Client { /// let user = client.get_user(661200758510977084u64).await.unwrap(); /// /// assert_eq!(user.username, "null"); - /// assert_eq!(user.discriminator, "8626"); /// assert_eq!(user.id, 661200758510977084u64); /// /// println!("{:?}", user); @@ -148,7 +149,6 @@ impl Client { /// let bot = client.get_bot(264811613708746752u64).await.unwrap(); /// /// assert_eq!(bot.username, "Luca"); - /// assert_eq!(bot.discriminator, "1375"); /// assert_eq!(bot.id, 264811613708746752u64); /// /// println!("{:?}", bot); @@ -237,11 +237,11 @@ impl Client { self.inner.post_stats(&new_stats).await } - /// Creates a new autoposter instance for this client which lets you automate the process of posting your Discord bot's statistics to the [Top.gg](https://top.gg) API. + /// Creates a new autoposter instance for this client which lets you automate the process of posting your Discord bot's statistics to the [Top.gg](https://top.gg) API in intervals. /// /// # Panics /// - /// Panics if the delay argument is shorter than 15 minutes (900 seconds) + /// Panics if the interval argument is shorter than 15 minutes (900 seconds) /// /// # Examples /// @@ -267,18 +267,17 @@ impl Client { /// ``` #[cfg(feature = "autoposter")] #[cfg_attr(docsrs, doc(cfg(feature = "autoposter")))] - #[must_use] - pub fn new_autoposter(&self, seconds_delay: D) -> Autoposter + pub fn new_autoposter(&self, seconds_interval: D) -> Autoposter where D: Into, { - let seconds_delay = seconds_delay.into(); + let seconds_interval = seconds_interval.into(); - if seconds_delay < 900 { - panic!("the delay mustn't be shorter than 15 minutes (900 seconds)"); + if seconds_interval < 900 { + panic!("the interval mustn't be shorter than 15 minutes (900 seconds)"); } - Autoposter::new(Arc::clone(&self.inner), seconds_delay) + Autoposter::new(Arc::clone(&self.inner), seconds_interval) } /// Fetches your Discord bot's last 1000 voters. @@ -355,8 +354,8 @@ impl Client { /// .certified(true); /// /// let query = Query::new() - /// .limit(250) - /// .skip(50) + /// .limit(250u16) + /// .skip(50u16) /// .filter(filter); /// /// for bot in client.get_bots(query).await.unwrap() { @@ -420,9 +419,9 @@ impl Client { self .inner .http - .request::(GET, &path, None) + .request(GET, &path, None) .await - .map(|res| unsafe { transmute(res.voted) }) + .map(|res: Voted| unsafe { transmute(res.voted) }) } /// Checks if the weekend multiplier is active. @@ -460,8 +459,8 @@ impl Client { self .inner .http - .request::(GET, "/weekend", None) + .request(GET, "/weekend", None) .await - .map(|res| res.is_weekend) + .map(|res: IsWeekend| res.is_weekend) } } diff --git a/src/http.rs b/src/http.rs index 10c98e3..a3a542d 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,7 +1,6 @@ use crate::{Error, InternalError, Result}; use serde::{de::DeserializeOwned, Deserialize}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; +use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::TcpStream}; use tokio_native_tls::{native_tls, TlsConnector}; pub(crate) const GET: &str = "GET"; @@ -13,6 +12,7 @@ pub(crate) struct Ratelimit { pub(crate) retry_after: u16, } +#[derive(Clone)] pub(crate) struct Http { token: String, } @@ -43,8 +43,9 @@ impl Http { let payload = format!( "\ - {predicate} /api{path} HTTP/1.0\r\n\ + {predicate} /api{path} HTTP/1.1\r\n\ Authorization: Bearer {}\r\n\ + Connection: close\r\n\ Content-Type: application/json\r\n\ Host: top.gg\r\n\ User-Agent: topgg (https://github.com/top-gg/rust-sdk) Rust/\r\n\ @@ -54,40 +55,43 @@ impl Http { body.unwrap_or_default() ); - if let Err(err) = socket.write_all(payload.as_bytes()).await { - return Err(Error::InternalClientError(InternalError::WriteRequest(err))); - } + socket + .write_all(payload.as_bytes()) + .await + .map_err(|err| Error::InternalClientError(InternalError::WriteRequest(err)))?; let mut response = String::new(); - if socket.read_to_string(&mut response).await.is_err() { - return Err(Error::InternalServerError); - } + socket + .read_to_string(&mut response) + .await + .map_err(|_| Error::InternalServerError)?; // we should never receive invalid raw HTTP responses - so unwrap_unchecked() is okay to use here - let status_code = unsafe { + let status_code: u16 = unsafe { response .split_ascii_whitespace() .nth(1) .unwrap_unchecked() - .parse::() + .parse() .unwrap_unchecked() }; - match status_code { - 401 => panic!("unauthorized"), - 404 => Err(Error::NotFound), - 429 => Err(Error::Ratelimit { - retry_after: serde_json::from_str::(&response) - .map_err(|_| Error::InternalServerError)? - .retry_after, - }), - 500.. => Err(Error::InternalServerError), - _ => { - response.drain(unsafe { ..response.find("\r\n\r\n").unwrap_unchecked() + 4 }); + if status_code >= 400 { + Err(match status_code { + 401 => panic!("unauthorized"), + 404 => Error::NotFound, + 429 => Error::Ratelimit { + retry_after: serde_json::from_str::(&response) + .map_err(|_| Error::InternalServerError)? + .retry_after, + }, + _ => Error::InternalServerError, + }) + } else { + response.drain(unsafe { ..response.find("\r\n\r\n").unwrap_unchecked() + 4 }); - Ok(response) - } + Ok(response) } } diff --git a/src/snowflake.rs b/src/snowflake.rs index fed5490..db5dd5b 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -1,42 +1,41 @@ -use serde::de::{Deserialize, Deserializer, Error}; +use serde::{de::Error, Deserialize, Deserializer}; +#[inline(always)] pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { - let s: &str = Deserialize::deserialize(deserializer)?; - - s.parse::().map_err(D::Error::custom) + Deserialize::deserialize(deserializer) + .and_then(|s: &str| s.parse::().map_err(D::Error::custom)) } +#[inline(always)] pub(crate) fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { - let s: Vec<&str> = Deserialize::deserialize(deserializer)?; - let out = Vec::with_capacity(s.len()); - - Ok(s.into_iter().fold(out, |mut acc, next| { - if let Ok(next) = next.parse::() { - acc.push(next); - } + Deserialize::deserialize(deserializer) + .map(|s: Vec<&str>| s.into_iter().filter_map(|next| next.parse().ok()).collect()) +} - acc - })) +mod private { + pub trait Sealed {} } /// A trait that represents any data type that can be interpreted as a snowflake/ID. -pub trait SnowflakeLike { +pub trait SnowflakeLike: private::Sealed { #[doc(hidden)] fn as_snowflake(&self) -> u64; } -macro_rules! impl_snowflake_tryfrom( +macro_rules! impl_snowflake_as( ($($t:ty),+) => {$( + impl private::Sealed for $t {} + impl SnowflakeLike for $t { #[inline(always)] fn as_snowflake(&self) -> u64 { - (*self).try_into().unwrap() + *self as _ } } )+} @@ -44,14 +43,16 @@ macro_rules! impl_snowflake_tryfrom( macro_rules! impl_snowflake_fromstr( ($($t:ty),+) => {$( + impl private::Sealed for $t {} + impl SnowflakeLike for $t { #[inline(always)] fn as_snowflake(&self) -> u64 { - self.parse().expect("invalid snowflake") + self.parse().unwrap() } } )+} ); -impl_snowflake_tryfrom!(u64, i128, u128, isize, usize); +impl_snowflake_as!(u64, i128, u128, isize, usize); impl_snowflake_fromstr!(str, String); diff --git a/src/user.rs b/src/user.rs index 1776f4d..fcccd32 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,27 +1,34 @@ use crate::{snowflake, util}; +use core::fmt::{self, Debug, Formatter}; use serde::Deserialize; /// A struct representing a user's social links. #[derive(Clone, Debug, Deserialize)] pub struct Socials { /// A URL to this user's GitHub account. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub github: Option, /// A URL to this user's Instagram account. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub instagram: Option, /// A URL to this user's Reddit account. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub reddit: Option, /// A URL to this user's Twitter account. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub twitter: Option, /// A URL to this user's YouTube channel. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub youtube: Option, } /// A struct representing a user logged into [Top.gg](https://top.gg). -#[derive(Clone, Debug, Deserialize)] +#[must_use] +#[derive(Clone, Deserialize)] pub struct User { /// The Discord ID of this user. #[serde(deserialize_with = "snowflake::deserialize")] @@ -30,13 +37,15 @@ pub struct User { /// The username of this user. pub username: String, - /// The Discord discriminator of this user. + #[deprecated(since = "1.1.0")] pub discriminator: String, /// The user's bio. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub bio: Option, /// A URL to this user's profile banner image. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] pub banner: Option, /// A struct of this user's social links. @@ -63,6 +72,7 @@ pub struct User { #[serde(rename = "admin")] pub is_admin: bool, + #[serde(default, deserialize_with = "util::deserialize_optional_string")] avatar: Option, } @@ -88,9 +98,29 @@ impl User { /// println!("{}", user.avatar()); /// } /// ``` + #[must_use] #[inline(always)] pub fn avatar(&self) -> String { - util::get_avatar(&self.avatar, &self.discriminator, self.id) + util::get_avatar(&self.avatar, self.id) + } +} + +impl Debug for User { + fn fmt(&self, fmt: &mut Formatter) -> fmt::Result { + fmt + .debug_struct("User") + .field("id", &self.id) + .field("username", &self.username) + .field("bio", &self.bio) + .field("banner", &self.banner) + .field("socials", &self.socials) + .field("is_supporter", &self.is_supporter) + .field("is_certified_dev", &self.is_certified_dev) + .field("is_moderator", &self.is_moderator) + .field("is_web_moderator", &self.is_web_moderator) + .field("is_admin", &self.is_admin) + .field("avatar", &self.avatar()) + .finish() } } @@ -100,7 +130,8 @@ pub(crate) struct Voted { } /// A struct representing a user who has voted on a Discord bot listed on [Top.gg](https://top.gg). (See [crate::Client::get_voters`]) -#[derive(Clone, Debug, Deserialize)] +#[must_use] +#[derive(Clone, Deserialize)] pub struct Voter { /// The Discord ID of this user. #[serde(deserialize_with = "snowflake::deserialize")] @@ -130,19 +161,23 @@ impl Voter { /// let client = Client::new(token); /// /// for voter in client.get_voters().await.unwrap() { - /// println!("{}", voter.avatar().unwrap_or(String::from("No avatar :("))); + /// println!("{}", voter.avatar()); /// } /// } /// ``` #[must_use] - pub fn avatar(&self) -> Option { - self.avatar.as_ref().map(|hash| { - let ext = if hash.starts_with("a_") { "gif" } else { "png" }; - - format!( - "https://cdn.discordapp.com/avatars/{}/{hash}.{ext}?size=1024", - self.id - ) - }) + pub fn avatar(&self) -> String { + util::get_avatar(&self.avatar, self.id) + } +} + +impl Debug for Voter { + fn fmt(&self, fmt: &mut Formatter) -> fmt::Result { + fmt + .debug_struct("Voter") + .field("id", &self.id) + .field("username", &self.username) + .field("avatar", &self.avatar()) + .finish() } } diff --git a/src/util.rs b/src/util.rs index 7b3e56e..050334b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,35 @@ -pub(crate) fn get_avatar(hash: &Option, discriminator: &str, id: u64) -> String { +use serde::{Deserialize, Deserializer}; + +#[inline(always)] +pub(crate) fn deserialize_optional_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok( + Deserialize::deserialize(deserializer) + .ok() + .and_then(|s: &str| { + if s.is_empty() { + None + } else { + Some(s.to_owned()) + } + }), + ) +} + +#[inline(always)] +pub(crate) fn deserialize_default<'de, D, T>(deserializer: D) -> Result +where + T: Default + Deserialize<'de>, + D: Deserializer<'de>, +{ + Option::deserialize(deserializer).map(|res| res.unwrap_or_default()) +} + +pub(crate) fn get_avatar(hash: &Option, id: u64) -> String { match hash { Some(hash) => { let ext = if hash.starts_with("a_") { "gif" } else { "png" }; @@ -6,8 +37,9 @@ pub(crate) fn get_avatar(hash: &Option, discriminator: &str, id: u64) -> format!("https://cdn.discordapp.com/avatars/{id}/{hash}.{ext}?size=1024") } - None => format!("https://cdn.discordapp.com/embed/avatars/{}.png", unsafe { - discriminator.parse::().unwrap_unchecked() % 5 - }), + _ => format!( + "https://cdn.discordapp.com/embed/avatars/{}.png", + (id >> 22) as u16 % 5 + ), } } diff --git a/src/webhook/actix.rs b/src/webhook/actix.rs index 9e3d030..f4f77a0 100644 --- a/src/webhook/actix.rs +++ b/src/webhook/actix.rs @@ -5,30 +5,49 @@ use actix_web::{ web::Json, FromRequest, HttpRequest, }; -use core::{future::Future, pin::Pin}; +use core::{ + future::Future, + pin::Pin, + task::{ready, Context, Poll}, +}; -#[cfg_attr(docsrs, doc(cfg(feature = "actix")))] -impl FromRequest for IncomingVote { - type Error = Error; - type Future = Pin>>>; +#[doc(hidden)] +pub struct IncomingVoteFut { + req: HttpRequest, + json_fut: as FromRequest>::Future, +} - fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { - let json = Json::::from_request(req, payload); - let req = req.clone(); +impl Future for IncomingVoteFut { + type Output = Result; - Box::pin(async move { - let headers = req.headers(); + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if let Ok(json) = ready!(Pin::new(&mut self.json_fut).poll(cx)) { + let headers = self.req.headers(); if let Some(authorization) = headers.get("Authorization") { if let Ok(authorization) = authorization.to_str() { - return Ok(Self { + return Poll::Ready(Ok(IncomingVote { authorization: authorization.to_owned(), - vote: json.await?.into_inner(), - }); + vote: json.into_inner(), + })); } } + } + + Poll::Ready(Err(ErrorUnauthorized("401"))) + } +} - Err(ErrorUnauthorized("401")) - }) +#[cfg_attr(docsrs, doc(cfg(feature = "actix")))] +impl FromRequest for IncomingVote { + type Error = Error; + type Future = IncomingVoteFut; + + #[inline(always)] + fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { + IncomingVoteFut { + req: req.clone(), + json_fut: Json::from_request(req, payload), + } } } diff --git a/src/webhook/axum.rs b/src/webhook/axum.rs index d1266e8..2bbf09c 100644 --- a/src/webhook/axum.rs +++ b/src/webhook/axum.rs @@ -65,7 +65,7 @@ where (StatusCode::UNAUTHORIZED, ()).into_response() } -/// Creates a new `axum` [`Router`] for adding an on-vote event handler to your application logic. +/// Creates a new [`axum`] [`Router`] for adding an on-vote event handler to your application logic. /// `state` here is your webhook handler. /// /// # Examples diff --git a/src/webhook/rocket.rs b/src/webhook/rocket.rs index 43ed186..0735f75 100644 --- a/src/webhook/rocket.rs +++ b/src/webhook/rocket.rs @@ -1,16 +1,15 @@ use crate::IncomingVote; use rocket::{ - data::{self, Data, FromDataSimple}, + data::{Data, FromDataSimple, Outcome}, http::Status, request::Request, - Outcome, }; #[cfg_attr(docsrs, doc(cfg(feature = "rocket")))] impl FromDataSimple for IncomingVote { type Error = (); - fn from_data(request: &Request<'_>, data: Data) -> data::Outcome { + fn from_data(request: &Request<'_>, data: Data) -> Outcome { let headers = request.headers(); if let Some(authorization) = headers.get_one("Authorization") { diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs index c0e2361..df71749 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -1,11 +1,9 @@ use crate::snowflake; -use serde::{ - de::{self, Deserializer}, - Deserialize, -}; +use serde::{Deserialize, Deserializer}; use std::collections::HashMap; /// A struct representing a dispatched [Top.gg](https://top.gg) bot/server vote event. +#[must_use] #[cfg_attr(docsrs, doc(cfg(feature = "webhook")))] #[derive(Clone, Debug, Deserialize)] pub struct Vote { @@ -30,9 +28,9 @@ pub struct Vote { #[serde(default, rename = "isWeekend")] pub is_weekend: bool, - /// Query strings found on the vote page, if any. + /// Query strings found on the vote page. #[serde(default, deserialize_with = "deserialize_query_string")] - pub query: Option>, + pub query: HashMap, } #[inline(always)] @@ -40,41 +38,38 @@ fn deserialize_is_test<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { - let s: &str = de::Deserialize::deserialize(deserializer)?; - - Ok(s == "test") + Deserialize::deserialize(deserializer).map(|s: &str| s == "test") } -fn deserialize_query_string<'de, D>( - deserializer: D, -) -> Result>, D::Error> +fn deserialize_query_string<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { - let s: Result<&str, D::Error> = de::Deserialize::deserialize(deserializer); - Ok( - s.map(|s| { - let mut output = HashMap::new(); + Deserialize::deserialize(deserializer) + .map(|s: &str| { + let mut output = HashMap::new(); - for mut it in s.split('&').map(|pair| pair.split('=')) { - if let (Some(k), Some(v)) = (it.next(), it.next()) { - if let Ok(v) = urlencoding::decode(v) { - output.insert(k.to_owned(), v.into_owned()); + for mut it in s.split('&').map(|pair| pair.split('=')) { + if let (Some(k), Some(v)) = (it.next(), it.next()) { + if let Ok(v) = urlencoding::decode(v) { + output.insert(k.to_owned(), v.into_owned()); + } } } - } - output - }) - .ok(), + output + }) + .unwrap_or_default(), ) } cfg_if::cfg_if! { if #[cfg(any(feature = "actix", feature = "rocket"))] { /// A struct that represents an unauthenticated request containing a [`Vote`] data. + #[must_use] #[cfg_attr(docsrs, doc(cfg(any(feature = "actix", feature = "rocket"))))] + #[derive(Clone)] pub struct IncomingVote { pub(crate) authorization: String, pub(crate) vote: Vote,