Skip to main content

rhood_core/client/
mod.rs

1//! The [`RobinhoodClient`] struct and its HTTP transport layer.
2//!
3//! All authenticated Robinhood API interactions flow through this module.
4//! Domain-specific endpoint methods are defined in [`crate::endpoints`] as
5//! `impl` blocks on `RobinhoodClient`.
6
7use 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
21/// Maximum consecutive server errors before aborting a polling loop.
22const MAX_SERVER_ERROR_RETRIES: u32 = 5;
23
24/// Default retry-after interval (in seconds) when the server does not provide one.
25const DEFAULT_RETRY_AFTER_SECS: u64 = 60;
26
27/// Default token type when the OAuth response omits it.
28const DEFAULT_TOKEN_TYPE: &str = "Bearer";
29
30/// Header name required by all Robinhood futures endpoints.
31const FUTURES_CONTRACT_HEADER: &str = "Rh-Contract-Protected";
32
33/// Header value required by all Robinhood futures endpoints.
34const FUTURES_CONTRACT_HEADER_VALUE: &str = "true";
35
36/// Authenticated client for the Robinhood REST API.
37///
38/// Holds HTTP transport, authentication state, device token, and configuration.
39/// Domain-specific methods (stocks, options, orders, account) are defined in
40/// [`crate::endpoints`] as `impl` blocks on this type.
41///
42/// # Authentication
43///
44/// Construct with [`new`](Self::new) or [`with_config`](Self::with_config),
45/// then call [`login`](Self::login) before any endpoint method. `login`
46/// follows a cascade: cached token → live validation → refresh →
47/// headless OAuth. If the server requires a verification code,
48/// `login` returns [`RhoodError::ChallengeRequired`] so collect the code
49/// from the user and complete the flow with
50/// [`submit_challenge_response`](Self::submit_challenge_response).
51///
52/// # Cloning
53///
54/// `RobinhoodClient` is cheap to clone: the HTTP transport, authentication
55/// state, device token, and configuration are all reference-counted
56/// internally. Cloning the client shares the same underlying auth state, so
57/// a refresh performed on one clone is visible on all others. The intended
58/// pattern for concurrent use is to construct a single client and clone it
59/// into each task.
60///
61/// # Timeouts
62///
63/// Every outbound call is bounded by two knobs on
64/// [`HttpConfig`]: `request_timeout_secs` is the
65/// total-call ceiling (headers + body) and `connect_timeout_secs` is the TCP
66/// connect ceiling. Defaults are 30s and 10s. A hung upstream is aborted
67/// with a timeout error rather than blocking the caller indefinitely.
68/// Override via `RHOOD_HTTP_REQUEST_TIMEOUT_SECS` /
69/// `RHOOD_HTTP_CONNECT_TIMEOUT_SECS` env vars or the corresponding CLI
70/// flags on `rhood-mcp serve`.
71///
72/// # Example
73///
74/// ```no_run
75/// use rhood_core::RobinhoodClient;
76///
77/// # async fn run() -> rhood_core::Result<()> {
78/// let client = RobinhoodClient::new()?;
79/// client.login_from_cache().await?;
80/// let portfolio = client.get_portfolio().await?;
81/// println!("equity: {:?}", portfolio.equity);
82/// # Ok(())
83/// # }
84/// ```
85#[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    /// Creates a new client using the default configuration loaded from
98    /// disk and environment variables.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if configuration loading or HTTP client construction fails.
103    pub fn new() -> Result<Self> {
104        let config = RhoodConfig::load(None)?;
105        Self::with_config(config)
106    }
107
108    /// Creates a new client with the given configuration.
109    ///
110    /// Construction is a pure operation: no filesystem writes occur here. The
111    /// token-cache directory is created lazily on first [`TokenCache::save`].
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the HTTP client fails to build.
116    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    /// Test-only: injects an authenticated state without performing OAuth.
140    ///
141    /// **SAFETY:** This method bypasses the entire OAuth flow and accepts
142    /// arbitrary tokens. The `test-helpers` feature must never be enabled
143    /// in production builds, published binaries, or `--all-features`
144    /// invocations of downstream consumers. Doing so exposes a public
145    /// method that can overwrite the client's authentication state.
146    ///
147    /// Compiled in two cases: the crate's own unit tests (`cfg(test)`), and
148    /// when the `test-helpers` feature is enabled for external integration
149    /// tests. It is never present in a normal (non-test) build.
150    #[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    /// Constructs a full URL by appending `path` to the configured API base URL.
166    pub fn api_url(&self, path: &str) -> String {
167        format!("{}{}", self.config.api.base_url, path)
168    }
169
170    /// Constructs a full URL by appending `path` to the configured Phoenix base URL.
171    pub fn phoenix_url(&self, path: &str) -> String {
172        format!("{}{}", self.config.api.phoenix_url, path)
173    }
174
175    /// Constructs a full URL by appending `path` to the configured Bonfire base URL.
176    pub fn bonfire_url(&self, path: &str) -> String {
177        format!("{}{}", self.config.api.bonfire_url, path)
178    }
179
180    /// Returns a reference to the client's configuration.
181    pub fn config(&self) -> &RhoodConfig {
182        &self.config
183    }
184
185    /// Returns a snapshot of the current authentication state.
186    ///
187    /// The state is cloned out from behind an internal read lock so the
188    /// returned value is an owned snapshot; later mutations on the client
189    /// will not be reflected in it.
190    pub async fn auth_state(&self) -> AuthState {
191        self.auth_state.read().await.clone()
192    }
193
194    /// Returns `true` if the client holds valid authentication tokens.
195    pub async fn is_authenticated(&self) -> bool {
196        self.auth_state.read().await.is_authenticated()
197    }
198
199    /// Clears the authentication state and deletes the on-disk token cache.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the token cache file cannot be deleted.
204    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        // Force authenticated state so we get past require_auth
376        *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}