spectacles_rest/lib.rs
1//! # Spectacles REST
2//! Spectacles REST offers a simple, easy-to-use client for making HTTP requests to the Discord API.
3//! All HTTP requests are made asynchronously and never block the thread, with the help of the Tokio runtime.
4//!
5//! ## Creating a Client
6//! A [`RestClient`], resoponsible for performing all requests, can be easily constructed with the `new` function.
7//! ```rust
8//! use spectacles_rest::RestClient;
9//! let token = std::env::var("DISCORD_TOKEN").expect("Failed to parse token");
10//! let rest = RestClient::new(token, true);
11//! ```
12//! The client accepts a boolean as a second parameter, which determines whether or not the internal rate limiter will be used on each request.
13//!
14//! ## Views
15//! The Client ships with three views for specific endpoints of the Discord API.
16//!
17//! The [`ChannelView`] provides a set of methods for interacting with a Discord channel.
18//!
19//! The [`GuildView`] provides a set of methods for interacting with a Discord guild, or "server".
20//!
21//! The [`WebhookView`] provides a set of methods for interacting with Discord webhooks.
22//!
23//! [`ChannelView`]: struct.ChannelView.html
24//! [`GuildView`]: struct.GuildView.html
25//! [`WebhookView`]: struct.WebhookView.html
26//! [`RestClient`]: struct.RestClient.html
27//!
28//! Here is a brief example of sending a message to a Discord channel using the ChannelView.
29//! ```rust, norun
30//! use tokio::prelude::*;
31//! use spectacles_rest::RestClient;
32//! use spectacles_model::Snowflake;
33//!
34//! fn main() {
35//! // Initialize the Rest Client, with a token.
36//! let rest = RestClient::new(token, true);
37//! tokio::run(rest.channel(&Snowflake(CHANNEL_ID_HERE)).create_message("Hello World")
38//! .map(|message| println!("Message Sent: {:?}", message))
39//! .map_err(|err| {
40//! eprintln!("Error whilst attempting to send message. {:?}", err);
41//! })
42//! );
43//! }
44//! ```
45//! ## Rate Limiting
46//! As mentioned earlier, the library includes an in-memory rate limiter bucket system for preemptively managing Discord Ratelimits.
47//! This is sufficient if you do not plan on accessing the Discord API from a single server.
48//! If you plan to make requests in a distributed fashion, you will need to make use of an external state for keeping track of rate limits.
49//!
50//! The library currently supports using a custom Discord proxy, featured in the Spectacles client, to be used as a central hub for handling requests.
51//! ```rust
52//! use spectacles_rest::RestClient;
53//! let proxy = std::env::var("PROXY").expect("Failed to parse proxy URL.");
54//! let rest = RestClient::new(token, false) // false tells the client to skip the default in memory rate limiter.
55//! .set_proxy(proxy);
56//!
57//! ```
58//! More rate limiting strategies will be considered in future.
59//!
60//!
61//! ## Installation
62//! Simply add the package to your Cargo.toml file.
63//! ```toml
64//! [dependencies]
65//! spectacles-rest = "0.1.0"
66//!
67
68#[macro_use]
69extern crate log;
70#[macro_use]
71extern crate serde_derive;
72#[warn(rust_2018_idioms)]
73#[macro_use]
74extern crate serde_json;
75
76use std::sync::Arc;
77
78use futures::future::{Future, Loop};
79use http::header::HeaderValue;
80use parking_lot::Mutex;
81use reqwest::header::HeaderMap;
82use reqwest::Method;
83use reqwest::r#async::{
84 Client as ReqwestClient,
85 ClientBuilder,
86 multipart::Form,
87};
88use serde::de::DeserializeOwned;
89use serde::ser::Serialize;
90use serde_json::Value;
91
92pub(crate) use ratelimit::*;
93use spectacles_model::channel::Channel;
94use spectacles_model::guild::{CreateGuildOptions, Guild};
95use spectacles_model::invite::Invite;
96use spectacles_model::snowflake::Snowflake;
97use spectacles_model::User;
98use spectacles_model::voice::VoiceRegion;
99/// A collection of interfaces for endpoint-specific Discord objects.
100pub use views::*;
101
102pub use crate::errors::{Error, Result};
103
104mod errors;
105mod ratelimit;
106mod views;
107mod constants;
108
109/// The Main client which is used to interface with the various components of the Discord API.
110#[derive(Clone, Debug)]
111pub struct RestClient {
112 /// The bot token for this user.
113 pub token: String,
114 /// The base URL of the client. This may be changed to accomodate an external proxy system.
115 pub base_url: String,
116 pub http: ReqwestClient,
117 ratelimiter: Option<Arc<Mutex<Ratelimter>>>,
118}
119
120impl RestClient {
121 /// Creates a new REST client with the provided configuration.
122 /// The second argument denotes whether or not to use the built-in rate limiter to rate limit requests to the Discord API.
123 /// If you plan to use a distributed architecture, you will need an external ratelimiter to ensure ratelimis are kept across servers.
124 pub fn new(token: String, using_ratelimiter: bool) -> Self {
125 let token = if token.starts_with("Bot ") {
126 token
127 } else {
128 format!("Bot {}", token)
129 };
130 let mut headers = HeaderMap::new();
131 let value = HeaderValue::from_str(&token).unwrap();
132 let agent = HeaderValue::from_str(
133 "DiscordBot (https://github.com/spec-tacles/spectacles-rs, v1.0.0)"
134 ).unwrap();
135 headers.insert("Authorization", value);
136 headers.insert("User-Agent", agent);
137
138 let client = ClientBuilder::new().default_headers(headers).build()
139 .expect("Failed to build HTTP client");
140
141 let mut rest = RestClient {
142 token,
143 http: client.clone(),
144 base_url: constants::BASE_URL.to_string(),
145 ratelimiter: None,
146 };
147
148 if using_ratelimiter {
149 rest.ratelimiter = Some(Arc::new(Mutex::new(Ratelimter::new(client))));
150 };
151
152 rest
153 }
154
155 /// Enables support for routing all requests though an HTTP rate limiting proxy.
156 /// If you plan on making distributed REST requests, an HTTP proxy is recommended for handling rate limits in a distributed manner.
157 pub fn set_proxy(mut self, url: String) -> Self {
158 self.base_url = url;
159 self
160 }
161
162 /// Opens a ChannelView for the provided Channel snowflake.
163 pub fn channel(&self, id: &Snowflake) -> ChannelView {
164 ChannelView::new(id.0, self.clone())
165 }
166
167 /// Opens a GuildView for the provided Guild snowflake.
168 pub fn guild(&self, id: &Snowflake) -> GuildView {
169 GuildView::new(id.0, self.clone())
170 }
171
172 /// Opens a WebhookView for the provided Webhook snowflake.
173 pub fn webhook(&self, id: &Snowflake) -> WebhookView {
174 WebhookView::new(id.0, self.clone())
175 }
176
177 /// Gets a User object for the provided snowflake.
178 pub fn get_user(&self, id: &Snowflake) -> impl Future<Item=User, Error=Error> {
179 self.request(Endpoint::new(
180 Method::GET,
181 format!("/users/{}", id.0),
182 ))
183 }
184
185 /// Opens a new DM channel with the user at the provided user ID.
186 pub fn create_dm(&self, user: &Snowflake) -> impl Future<Item=Channel, Error=Error> {
187 let json = json!({
188 "recipient_id": user.0
189 });
190
191 self.request(Endpoint::new(
192 Method::POST,
193 String::from("/users/@me/channels"),
194 ).json(json))
195 }
196
197 /// Creates a new guild, setting the current client user as owner.
198 /// This endpoint may only be used for bots who are in less than 10 guilds.
199 pub fn create_guild(&self, opts: CreateGuildOptions) -> impl Future<Item=Guild, Error=Error> {
200 self.request(Endpoint::new(
201 Method::POST,
202 String::from("/guilds"),
203 ).json(opts))
204 }
205
206 /// Leaves the guild using the provided guild ID.
207 pub fn leave_guild(&self, id: &Snowflake) -> impl Future<Item=(), Error=Error> {
208 self.request_empty(Endpoint::new(
209 Method::DELETE,
210 format!("/users/@me/guilds/{}", id.0),
211 ))
212 }
213
214
215 /// Modifies properties for the current user.
216 /*pub fn modify_current_user(&self) -> impl Future<Item = User, Error = Error> {
217 self.client.request(Endpoint::new(
218 Method::PATCH,
219 String::from("/users/@me)",
220 ))
221 }*/
222
223 /// Obtains a list of Discord voice regions.
224 pub fn get_voice_regions(&self) -> impl Future<Item=Vec<VoiceRegion>, Error=Error> {
225 self.request(Endpoint::new(
226 Method::GET,
227 String::from("/voice/regions"),
228 ))
229 }
230
231 /// Obtains an invite object from Discord using the given code.
232 /// The second argument denotes whether the invite should contain approximate member counts
233 pub fn get_invite(&self, code: &str, member_counts: bool) -> impl Future<Item=Invite, Error=Error> {
234 self.request(Endpoint::new(
235 Method::GET,
236 format!("/invites/{}?with_counts={}", code, member_counts),
237 ))
238 }
239
240 /// Deletes this invite from the its parent channel.
241 /// This requires that the client have the `MANAGE_CHANNELS` permission.
242 pub fn delete_invite(&self, code: &str) -> impl Future<Item=Invite, Error=Error> {
243 self.request(Endpoint::new(
244 Method::GET,
245 format!("/invites/{}", code),
246 ))
247 }
248
249 /// Makes an HTTP request to the provided Discord API endpoint.
250 /// Depending on the ratelimiter status, the request may or may not be rate limited.
251 pub fn request<T>(&self, mut endpt: Endpoint) -> Box<Future<Item=T, Error=Error> + Send>
252 where T: DeserializeOwned + Send + 'static
253 {
254 if let Some(ref rl) = self.ratelimiter {
255 let http = self.http.clone();
256 let base = self.base_url.clone();
257 Box::new(futures::future::loop_fn(Arc::clone(rl), move |ratelimit| {
258 let req_url = format!("{}{}", base, &endpt.url);
259 let route = Bucket::make_route(endpt.method.clone(), req_url.clone());
260 let mut req = http.request(endpt.method.clone(), &req_url)
261 .query(&endpt.query)
262 .json(&endpt.json);
263 if endpt.multipart.is_some() {
264 let form = endpt.multipart.take().unwrap();
265 req = req.multipart(form);
266 };
267 let limiter = Arc::clone(&ratelimit);
268 let limiter_2 = Arc::clone(&limiter);
269 ratelimit.lock().enqueue(route.clone())
270 .and_then(|_| req.send().from_err())
271 .and_then(move |resp| limiter.lock().handle_resp(route, resp))
272 .map(move |status| match status {
273 ResponseStatus::Success(resp) => Loop::Break(resp),
274 ResponseStatus::Ratelimited | ResponseStatus::ServerError => Loop::Continue(limiter_2)
275 })
276 }).and_then(|mut resp| resp.json().from_err()))
277 } else {
278 let req_url = format!("{}{}", self.base_url, &endpt.url);
279 let req = self.http.request(endpt.method.clone(), &req_url)
280 .query(&endpt.query)
281 .json(&endpt.json);
282 Box::new(req.send().map_err(Error::from)
283 .and_then(|mut resp| resp.json().from_err())
284 )
285 }
286 }
287
288 /// Similar to the above method, but does not attempt to deserialize a JSON payload from the request.
289 /// Use this method if you are dealing with routes that return 204 (No content).
290 pub fn request_empty(&self, mut endpt: Endpoint) -> Box<Future<Item=(), Error=Error> + Send> {
291 if let Some(ref rl) = self.ratelimiter {
292 let http = self.http.clone();
293 let base = self.base_url.clone();
294 Box::new(futures::future::loop_fn(Arc::clone(rl), move |ratelimit| {
295 let req_url = format!("{}{}", base, &endpt.url);
296 let route = Bucket::make_route(endpt.method.clone(), req_url.clone());
297 let mut req = http.request(endpt.method.clone(), &req_url)
298 .query(&endpt.query)
299 .json(&endpt.json);
300 if endpt.multipart.is_some() {
301 let form = endpt.multipart.take().unwrap();
302 req = req.multipart(form);
303 };
304 let limiter = Arc::clone(&ratelimit);
305 let limiter_2 = Arc::clone(&limiter);
306 ratelimit.lock().enqueue(route.clone())
307 .and_then(|_| req.send().from_err())
308 .and_then(move |resp| limiter.lock().handle_resp(route, resp))
309 .map(move |status| match status {
310 ResponseStatus::Success(resp) => Loop::Break(resp),
311 ResponseStatus::Ratelimited | ResponseStatus::ServerError => Loop::Continue(limiter_2)
312 })
313 }).map(|_| ()))
314 } else {
315 let req_url = format!("{}{}", self.base_url, &endpt.url);
316 let req = self.http.request(endpt.method.clone(), &req_url)
317 .query(&endpt.query)
318 .json(&endpt.json);
319 Box::new(req.send().map_err(Error::from)
320 .map(|_| ())
321 )
322 }
323 }
324}
325
326/// A structure representing a Discord API endpoint, in the context of an HTTP request.
327#[derive(Debug)]
328pub struct Endpoint {
329 url: String,
330 method: Method,
331 json: Option<Value>,
332 query: Option<Value>,
333 multipart: Option<Form>,
334}
335
336impl Endpoint {
337 /// Creates a new endpoint from the following HTTP method and URL string.
338 pub fn new(method: Method, url: String) -> Self {
339 Self {
340 method,
341 url,
342 json: None,
343 query: None,
344 multipart: None,
345 }
346 }
347
348 /// Adds a json body to the request.
349 pub fn json<T: Serialize>(mut self, payload: T) -> Endpoint {
350 match serde_json::to_value(payload) {
351 Ok(val) => self.json = Some(val),
352 Err(_) => self.json = None
353 };
354
355 self
356 }
357
358 /// Adds a query parameter to the endpoint.
359 pub fn query<T: Serialize>(mut self, payload: T) -> Endpoint {
360 match serde_json::to_value(payload) {
361 Ok(val) => self.query = Some(val),
362 Err(_) => self.query = None
363 };
364
365 self
366 }
367
368 /// Adds a multipart form to the endpoint, which is useful for sending files to the Discord API.
369 pub fn multipart(mut self, payload: Form) -> Endpoint {
370 self.multipart = Some(payload);
371 self
372 }
373}