roboat/
client.rs

1use crate::users::ClientUserInformation;
2use crate::RoboatError;
3use reqwest::header::HeaderValue;
4// We use tokio's version of rwlock so that readers to not starve writers on linux.
5use tokio::sync::RwLock;
6
7/// A client used for making requests to the Roblox API.
8///
9/// The client stores the roblosecurity cookie, X-CSRF-TOKEN header, and an HTTPS client to send web
10/// requests. The client also caches the user id, username, and display name of the user.
11///
12/// Constructed using a [`ClientBuilder`].
13///
14/// # Construction Examples
15///
16/// ## Without Roblosecurity or a Custom Reqwest Client
17/// ```
18/// use roboat::ClientBuilder;
19///
20/// let client = ClientBuilder::new().build();
21/// ```
22///
23/// ## With a Roblosecurity
24/// ```
25/// use roboat::ClientBuilder;
26///
27/// const ROBLOSECURITY: &str = "roblosecurity";
28///
29/// let client = ClientBuilder::new().roblosecurity(ROBLOSECURITY.to_string()).build();
30/// ```
31///
32/// ## With a Custom Reqwest Client
33/// ```
34/// use roboat::ClientBuilder;
35///
36/// let reqwest_client = reqwest::Client::new();
37/// let client = ClientBuilder::new().reqwest_client(reqwest_client).build();
38/// ```
39///
40/// ## With a Roblosecurity and a Custom Reqwest Client
41/// ```
42/// use roboat::ClientBuilder;
43///
44/// const ROBLOSECURITY: &str = "roblosecurity";
45///
46/// let reqwest_client = reqwest::Client::new();
47/// let client = ClientBuilder::new().roblosecurity(ROBLOSECURITY.to_string()).reqwest_client(reqwest_client).build();
48/// ```
49///
50/// # Standard Errors
51/// The errors that can be returned by any of `Client`'s methods are:
52/// - [`RoboatError::TooManyRequests`]
53/// - [`RoboatError::InternalServerError`]
54/// - [`RoboatError::BadRequest`]
55/// - [`RoboatError::UnknownRobloxErrorCode`]
56/// - [`RoboatError::UnidentifiedStatusCode`]
57/// - [`RoboatError::ReqwestError`]
58///
59/// # Auth Required Errors
60/// The errors that can be returned by any of `Client`'s methods that require authentication are:
61/// - [`RoboatError::InvalidRoblosecurity`]
62/// - [`RoboatError::RoblosecurityNotSet`]
63///
64/// # X-CSRF-TOKEN Required Errors
65/// The errors that can be returned by any of `Client`'s methods that require the X-CSRF-TOKEN header are:
66/// - [`RoboatError::InvalidXcsrf`]
67/// - [`RoboatError::XcsrfNotReturned`]
68///
69/// # 2-Factor Authentication / Captcha Required Errors
70/// The errors that can be returned by any of `Client`'s methods that require 2-factor authentication or a captcha are:
71/// - [`RoboatError::ChallengeRequired`]
72/// - [`RoboatError::UnknownStatus403Format`]
73#[derive(Debug, Default)]
74pub struct Client {
75    /// The full cookie that includes the roblosecurity token.
76    pub(crate) cookie_string: Option<HeaderValue>,
77    /// The field holding the value for the X-CSRF-TOKEN header used in and returned by endpoints.
78    pub(crate) xcsrf: RwLock<String>,
79    /// Holds the user id, username, and display name of the user.
80    pub(crate) user_information: RwLock<Option<ClientUserInformation>>,
81    /// A Reqwest HTTP client used to send web requests.
82    pub(crate) reqwest_client: reqwest::Client,
83}
84
85/// A builder used for constructing a [`Client`]. Constructed using [`ClientBuilder::new`].
86#[derive(Clone, Debug, Default)]
87pub struct ClientBuilder {
88    roblosecurity: Option<String>,
89    reqwest_client: Option<reqwest::Client>,
90}
91
92impl Client {
93    /// Returns the user id of the user. If the user id is not cached, it will be fetched from Roblox first.
94    ///
95    /// The user id should be the only thing used to differentiate between accounts as
96    /// username and display name can change.
97    pub async fn user_id(&self) -> Result<u64, RoboatError> {
98        let guard = self.user_information.read().await;
99        let user_information_opt = &*guard;
100
101        match user_information_opt {
102            Some(user_information) => Ok(user_information.user_id),
103            None => {
104                // Drop the read lock as the writer lock will be requested.
105                drop(guard);
106
107                let user_info = self.user_information_internal().await?;
108                Ok(user_info.user_id)
109            }
110        }
111    }
112
113    /// Returns the username of the user. If the username is not cached, it will be fetched from Roblox first.
114    ///
115    /// Username can change (although rarely). For this reason only user id should be used for differentiating accounts.
116    pub async fn username(&self) -> Result<String, RoboatError> {
117        let guard = self.user_information.read().await;
118        let user_information_opt = &*guard;
119
120        match user_information_opt {
121            Some(user_information) => Ok(user_information.username.clone()),
122            None => {
123                // Drop the read lock as the writer lock will be requested.
124                drop(guard);
125
126                let user_info = self.user_information_internal().await?;
127                Ok(user_info.username)
128            }
129        }
130    }
131
132    /// Returns the display name of the user. If the display name is not cached, it will be fetched from Roblox first.
133    ///
134    /// Display name can change. For this reason only user id should be used for differentiating accounts.
135    pub async fn display_name(&self) -> Result<String, RoboatError> {
136        let guard = self.user_information.read().await;
137        let user_information_opt = &*guard;
138
139        match user_information_opt {
140            Some(user_information) => Ok(user_information.display_name.clone()),
141            None => {
142                // Drop the read lock as the writer lock will be requested.
143                drop(guard);
144
145                let user_info = self.user_information_internal().await?;
146                Ok(user_info.display_name)
147            }
148        }
149    }
150
151    /// Used in [`Client::user_information_internal`]. This is implemented in the client
152    /// module as we do not want other modules to have to interact with the rwlock directly.
153    pub(crate) async fn set_user_information(&self, user_information: ClientUserInformation) {
154        *self.user_information.write().await = Some(user_information);
155    }
156
157    /// Sets the xcsrf token of the client. Remember to .await this method.
158    pub(crate) async fn set_xcsrf(&self, xcsrf: String) {
159        *self.xcsrf.write().await = xcsrf;
160    }
161
162    /// Returns a copy of the xcsrf stored in the client. Remember to .await this method.
163    pub(crate) async fn xcsrf(&self) -> String {
164        self.xcsrf.read().await.clone()
165    }
166
167    /// Returns a copy of the cookie string stored in the client.
168    /// If the roblosecurity has not been set, [`RoboatError::RoblosecurityNotSet`] is returned.
169    pub(crate) fn cookie_string(&self) -> Result<HeaderValue, RoboatError> {
170        let cookie_string_opt = &self.cookie_string;
171
172        match cookie_string_opt {
173            Some(cookie) => Ok(cookie.clone()),
174            None => Err(RoboatError::RoblosecurityNotSet),
175        }
176    }
177}
178
179impl ClientBuilder {
180    /// Creates a new [`ClientBuilder`].
181    pub fn new() -> Self {
182        Self::default()
183    }
184
185    /// Sets the roblosecurity for the client.
186    ///
187    /// # Example
188    /// ```rust
189    /// use roboat::ClientBuilder;
190    ///
191    /// const ROBLOSECURITY: &str = "roblosecurity";
192    ///
193    /// let client = ClientBuilder::new().roblosecurity(ROBLOSECURITY.to_string()).build();
194    /// ```
195    pub fn roblosecurity(mut self, roblosecurity: String) -> Self {
196        self.roblosecurity = Some(roblosecurity);
197        self
198    }
199
200    /// Sets the [`reqwest::Client`] for the client.
201    ///
202    /// # Example
203    /// ```rust
204    /// use roboat::ClientBuilder;
205    ///
206    /// let reqwest_client = reqwest::Client::new();
207    /// let client = ClientBuilder::new().reqwest_client(reqwest_client).build();
208    /// ```
209    pub fn reqwest_client(mut self, reqwest_client: reqwest::Client) -> Self {
210        self.reqwest_client = Some(reqwest_client);
211        self
212    }
213
214    /// Builds the [`Client`]. This consumes the builder.
215    ///
216    /// # Example
217    /// ```rust
218    /// use roboat::ClientBuilder;
219    ///
220    /// let client = ClientBuilder::new().build();
221    /// ```
222    pub fn build(self) -> Client {
223        Client {
224            cookie_string: self
225                .roblosecurity
226                .as_ref()
227                .map(|x| create_cookie_string_header(x)),
228            reqwest_client: self.reqwest_client.unwrap_or_default(),
229            ..Default::default()
230        }
231    }
232}
233
234fn create_cookie_string_header(roblosecurity: &str) -> HeaderValue {
235    // We panic here because I really really really hope that nobody is using invalid characters in their roblosecurity.
236    let mut header = HeaderValue::from_str(&format!(".ROBLOSECURITY={}", roblosecurity))
237        .expect("Invalid roblosecurity characters.");
238
239    header.set_sensitive(true);
240
241    header
242}