1use crate::auth::{AuthState, TokenCache};
8use crate::config::{HttpConfig, RhoodConfig};
9use crate::resolver_cache::ResolverCache;
10use crate::{Result, RhoodError};
11use reqwest::header::{HeaderMap, HeaderValue};
12use secrecy::ExposeSecret;
13#[cfg(any(test, feature = "test-helpers"))]
14use secrecy::SecretString;
15use std::path::PathBuf;
16use std::sync::Arc;
17use std::time::Duration;
18use tokio::sync::RwLock;
19use uuid::Uuid;
20
21const MAX_SERVER_ERROR_RETRIES: u32 = 5;
23
24const DEFAULT_RETRY_AFTER_SECS: u64 = 60;
26
27const DEFAULT_TOKEN_TYPE: &str = "Bearer";
29
30const FUTURES_CONTRACT_HEADER: &str = "Rh-Contract-Protected";
32
33const FUTURES_CONTRACT_HEADER_VALUE: &str = "true";
35
36#[derive(Clone)]
86pub struct RobinhoodClient {
87 http: reqwest::Client,
88 auth_state: Arc<RwLock<AuthState>>,
89 token_cache: TokenCache,
90 device_token: Arc<RwLock<String>>,
91 config: Arc<RhoodConfig>,
92 read_only: bool,
93 pub(crate) resolvers: ResolverCache,
94}
95
96impl RobinhoodClient {
97 pub fn new() -> Result<Self> {
104 let config = RhoodConfig::load(None)?;
105 Self::with_config(config)
106 }
107
108 pub fn with_config(config: RhoodConfig) -> Result<Self> {
117 let device_token = config
118 .auth
119 .device_token
120 .as_ref()
121 .map(|secret| secret.expose_secret().to_string())
122 .unwrap_or_else(|| Uuid::new_v4().to_string());
123 let cache_path = PathBuf::from(&config.auth.token_cache_path);
124 let token_cache = TokenCache::with_path(cache_path);
125 let http = build_http_client(&config.http)?;
126 let read_only = config.read_only;
127 let resolvers = ResolverCache::from_config(&config.cache);
128 Ok(Self {
129 http,
130 auth_state: Arc::new(RwLock::new(AuthState::Unauthenticated)),
131 token_cache,
132 read_only,
133 device_token: Arc::new(RwLock::new(device_token)),
134 config: Arc::new(config),
135 resolvers,
136 })
137 }
138
139 #[cfg(any(test, feature = "test-helpers"))]
151 #[doc(hidden)]
152 pub async fn inject_test_auth(
153 &self,
154 access_token: SecretString,
155 token_type: String,
156 refresh_token: SecretString,
157 ) {
158 *self.auth_state.write().await = AuthState::Authenticated {
159 access_token,
160 token_type,
161 refresh_token,
162 };
163 }
164
165 pub fn api_url(&self, path: &str) -> String {
167 format!("{}{}", self.config.api.base_url, path)
168 }
169
170 pub fn phoenix_url(&self, path: &str) -> String {
172 format!("{}{}", self.config.api.phoenix_url, path)
173 }
174
175 pub fn bonfire_url(&self, path: &str) -> String {
177 format!("{}{}", self.config.api.bonfire_url, path)
178 }
179
180 pub fn config(&self) -> &RhoodConfig {
182 &self.config
183 }
184
185 pub async fn auth_state(&self) -> AuthState {
191 self.auth_state.read().await.clone()
192 }
193
194 pub async fn is_authenticated(&self) -> bool {
196 self.auth_state.read().await.is_authenticated()
197 }
198
199 pub async fn logout(&self) -> Result<()> {
205 *self.auth_state.write().await = AuthState::Unauthenticated;
206 self.token_cache.clear()?;
207 Ok(())
208 }
209
210 async fn require_auth(&self) -> Result<String> {
211 self.auth_state
212 .read()
213 .await
214 .authorization_header()
215 .ok_or(RhoodError::NotAuthenticated)
216 }
217
218 pub(crate) fn require_writable(&self) -> Result<()> {
219 if self.read_only {
220 return Err(RhoodError::ReadOnlyMode);
221 }
222 Ok(())
223 }
224}
225
226fn build_http_client(http_config: &HttpConfig) -> Result<reqwest::Client> {
227 let mut headers = HeaderMap::new();
228 headers.insert("Accept", HeaderValue::from_static("*/*"));
229 headers.insert("Accept-Language", HeaderValue::from_static("en-US,en;q=1"));
230 headers.insert(
231 "Content-Type",
232 HeaderValue::from_static("application/x-www-form-urlencoded; charset=utf-8"),
233 );
234 headers.insert(
235 "X-Robinhood-API-Version",
236 HeaderValue::from_static("1.431.4"),
237 );
238 headers.insert("User-Agent", HeaderValue::from_static("*"));
239
240 reqwest::Client::builder()
241 .default_headers(headers)
242 .cookie_store(true)
243 .timeout(Duration::from_secs(http_config.request_timeout_secs))
244 .connect_timeout(Duration::from_secs(http_config.connect_timeout_secs))
245 .build()
246 .map_err(RhoodError::Http)
247}
248
249mod auth;
250mod device_verification;
251mod transport;
252
253#[cfg(test)]
254fn test_config(cache_path: &str) -> crate::config::RhoodConfig {
255 let mut cfg = crate::config::RhoodConfig::default();
256 cfg.auth.token_cache_path = cache_path.to_string();
257 cfg
258}
259
260#[cfg(test)]
261fn test_config_with_tempdir(dir: &tempfile::TempDir) -> crate::config::RhoodConfig {
262 let cache_path = dir.path().join("nonexistent-token.json");
263 test_config(cache_path.to_str().unwrap())
264}
265
266#[cfg(test)]
267fn default_oauth_response() -> crate::models::auth::OAuthResponse {
268 crate::models::auth::OAuthResponse {
269 access_token: None,
270 token_type: None,
271 refresh_token: None,
272 _expires_in: None,
273 _scope: None,
274 _user_uuid: None,
275 _backup_code: None,
276 mfa_required: None,
277 _mfa_code: None,
278 verification_workflow: None,
279 challenge: None,
280 detail: None,
281 }
282}
283
284#[cfg(test)]
285#[expect(
286 clippy::assertions_on_result_states,
287 reason = "these tests intentionally assert Result state without unwrapping so they remain compatible with unwrap_used"
288)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn build_client_succeeds() {
294 assert!(build_http_client(&HttpConfig::default()).is_ok());
295 }
296
297 #[tokio::test]
298 async fn require_auth_when_unauthenticated() {
299 let dir = tempfile::tempdir().unwrap();
300 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
301 assert!(matches!(
302 client.require_auth().await,
303 Err(RhoodError::NotAuthenticated)
304 ));
305 }
306
307 #[test]
308 fn url_helpers_compose_correctly() {
309 let dir = tempfile::tempdir().unwrap();
310 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
311 assert_eq!(
312 client.api_url("/oauth2/token/"),
313 "https://api.robinhood.com/oauth2/token/"
314 );
315 assert_eq!(
316 client.phoenix_url("/accounts/"),
317 "https://phoenix.robinhood.com/accounts/"
318 );
319 }
320
321 #[test]
322 fn bonfire_url_helper_composes_correctly() {
323 let dir = tempfile::tempdir().unwrap();
324 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
325 assert_eq!(
326 client.bonfire_url("/accounts/unified"),
327 "https://bonfire.robinhood.com/accounts/unified"
328 );
329 }
330
331 #[test]
332 fn config_accessor_returns_config() {
333 let dir = tempfile::tempdir().unwrap();
334 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
335 assert_eq!(client.config().api.base_url, "https://api.robinhood.com");
336 }
337
338 #[test]
339 fn require_writable_when_explicitly_enabled() {
340 let dir = tempfile::tempdir().unwrap();
341 let mut cfg = test_config_with_tempdir(&dir);
342 cfg.read_only = false;
343 let client = RobinhoodClient::with_config(cfg).unwrap();
344 assert!(client.require_writable().is_ok());
345 }
346
347 #[test]
348 fn require_writable_default_blocks() {
349 let dir = tempfile::tempdir().unwrap();
350 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
351 assert!(matches!(
352 client.require_writable(),
353 Err(RhoodError::ReadOnlyMode)
354 ));
355 }
356
357 #[test]
358 fn require_writable_read_only_blocks() {
359 let dir = tempfile::tempdir().unwrap();
360 let mut cfg = test_config_with_tempdir(&dir);
361 cfg.read_only = true;
362 let client = RobinhoodClient::with_config(cfg).unwrap();
363 assert!(matches!(
364 client.require_writable(),
365 Err(RhoodError::ReadOnlyMode)
366 ));
367 }
368
369 #[tokio::test]
370 async fn read_only_blocks_cancel_stock_order() {
371 let dir = tempfile::tempdir().unwrap();
372 let mut cfg = test_config_with_tempdir(&dir);
373 cfg.read_only = true;
374 let client = RobinhoodClient::with_config(cfg).unwrap();
375 *client.auth_state.write().await = AuthState::Authenticated {
377 access_token: SecretString::from("fake"),
378 token_type: "Bearer".to_string(),
379 refresh_token: SecretString::from("fake"),
380 };
381 let err = client.cancel_stock_order("fake-id").await.unwrap_err();
382 assert!(matches!(err, RhoodError::ReadOnlyMode));
383 }
384}