ig_client/session/
interface.rs

1use crate::config::Config;
2use crate::error::{AppError, AuthError};
3use crate::session::response::OAuthToken;
4use crate::utils::rate_limiter::{
5    RateLimitType, RateLimiter, RateLimiterStats, app_non_trading_limiter, create_rate_limiter,
6};
7use chrono::{DateTime, Utc};
8use std::sync::Arc;
9use std::sync::Mutex;
10use std::sync::atomic::{AtomicBool, Ordering};
11use tracing::debug;
12
13/// Timer for managing IG API token expiration and refresh cycles
14///
15/// According to IG API documentation, tokens are initially valid for 6 hours
16/// but get extended up to a maximum of 72 hours while they are in use.
17#[derive(Debug, Clone)]
18pub struct TokenTimer {
19    /// The current expiry time of the token (initially 6 hours from creation)
20    pub expiry: DateTime<Utc>,
21    /// The timestamp when the token was last refreshed
22    pub last_refreshed: DateTime<Utc>,
23    /// The maximum age the token can reach (72 hours from initial creation)
24    pub max_age: DateTime<Utc>,
25}
26
27impl Default for TokenTimer {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl TokenTimer {
34    /// Creates a new TokenTimer with initial 6-hour expiry and 72-hour maximum age
35    ///
36    /// # Returns
37    /// A new TokenTimer instance with expiry set to 6 hours from now and max_age set to 72 hours from now
38    pub fn new() -> Self {
39        let expiry = Utc::now() + chrono::Duration::hours(6);
40        let max_age = Utc::now() + chrono::Duration::hours(72);
41        Self {
42            expiry,
43            last_refreshed: Utc::now(),
44            max_age,
45        }
46    }
47
48    /// Checks if the token is expired based on current time
49    ///
50    /// # Returns
51    /// `true` if either the token expiry time or maximum age has been reached, `false` otherwise
52    pub fn is_expired(&self) -> bool {
53        self.expiry <= Utc::now() || self.max_age <= Utc::now()
54    }
55
56    /// Checks if the token is expired or will expire within the given margin
57    ///
58    /// # Arguments
59    /// * `margin` - The time margin to check before actual expiry
60    ///
61    /// # Returns
62    /// `true` if the token will expire within the margin or has already expired, `false` otherwise
63    pub fn is_expired_w_margin(&self, margin: chrono::Duration) -> bool {
64        self.expiry - margin <= Utc::now() || self.max_age - margin <= Utc::now()
65    }
66
67    /// Refreshes the token timer, extending the expiry time by 6 hours from now
68    ///
69    /// This should be called after each successful API request to extend token validity.
70    /// The expiry time is reset to 6 hours from the current time, but cannot exceed max_age.
71    pub fn refresh(&mut self) {
72        self.expiry = Utc::now() + chrono::Duration::hours(6);
73        self.last_refreshed = Utc::now();
74    }
75}
76
77/// Session information for IG Markets API authentication
78///
79/// Supports both API v2 (CST/X-SECURITY-TOKEN) and v3 (OAuth) authentication.
80#[derive(Debug, Clone)]
81pub struct IgSession {
82    /// Client Session Token (CST) used for authentication (API v2)
83    pub cst: String,
84    /// Security token used for authentication (API v2)
85    pub token: String,
86    /// OAuth token information (API v3)
87    pub oauth_token: Option<OAuthToken>,
88    /// Account ID associated with the session
89    pub account_id: String,
90    /// Base URL for API requests
91    pub base_url: String,
92    /// Client ID for API requests
93    pub client_id: String,
94    /// Lightstreamer endpoint for API requests
95    pub lightstreamer_endpoint: String,
96    /// API key for API requests
97    pub api_key: String,
98    /// Rate limiter for controlling request rates
99    pub(crate) rate_limiter: Option<Arc<RateLimiter>>,
100    /// Flag to indicate if the session is being used in a concurrent context
101    pub(crate) concurrent_mode: Arc<AtomicBool>,
102    /// Timer for managing token expiration and automatic refresh cycles
103    pub token_timer: Arc<Mutex<TokenTimer>>,
104}
105
106impl IgSession {
107    /// Creates a new session with the given credentials
108    ///
109    /// This is a simplified version for tests and basic usage.
110    /// Uses default values for most fields and a default rate limiter.
111    pub fn new(cst: String, token: String, account_id: String) -> Self {
112        Self {
113            base_url: String::new(),
114            cst,
115            token,
116            oauth_token: None,
117            client_id: String::new(),
118            account_id,
119            lightstreamer_endpoint: String::new(),
120            api_key: String::new(),
121            rate_limiter: Some(create_rate_limiter(
122                RateLimitType::NonTradingAccount,
123                Some(0.8),
124            )),
125            concurrent_mode: Arc::new(AtomicBool::new(false)),
126            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
127        }
128    }
129
130    /// Creates a new session with the given parameters
131    ///
132    /// This creates a thread-safe session that can be shared across multiple threads.
133    /// The rate limiter is wrapped in an Arc to ensure proper synchronization.
134    #[allow(clippy::too_many_arguments)]
135    pub fn new_with_config(
136        base_url: String,
137        cst: String,
138        security_token: String,
139        client_id: String,
140        account_id: String,
141        lightstreamer_endpoint: String,
142        api_key: String,
143        rate_limit_type: RateLimitType,
144        rate_limit_safety_margin: f64,
145    ) -> Self {
146        // Create a rate limiter with the specified type and safety margin
147        let rate_limiter = create_rate_limiter(rate_limit_type, Some(rate_limit_safety_margin));
148
149        Self {
150            base_url,
151            cst,
152            token: security_token,
153            oauth_token: None,
154            client_id,
155            account_id,
156            lightstreamer_endpoint,
157            api_key,
158            rate_limiter: Some(rate_limiter),
159            concurrent_mode: Arc::new(AtomicBool::new(false)),
160            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
161        }
162    }
163
164    /// Creates a new session with the given credentials and a rate limiter
165    ///
166    /// This creates a thread-safe session that can be shared across multiple threads.
167    pub fn with_rate_limiter(
168        cst: String,
169        token: String,
170        account_id: String,
171        limit_type: RateLimitType,
172    ) -> Self {
173        Self {
174            cst,
175            token,
176            oauth_token: None,
177            account_id,
178            base_url: String::new(),
179            client_id: String::new(),
180            lightstreamer_endpoint: String::new(),
181            api_key: String::new(),
182            rate_limiter: Some(create_rate_limiter(limit_type, Some(0.8))),
183            concurrent_mode: Arc::new(AtomicBool::new(false)),
184            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
185        }
186    }
187
188    /// Creates a new session with the given credentials and rate limiter configuration from Config
189    pub fn from_config(cst: String, token: String, account_id: String, config: &Config) -> Self {
190        Self {
191            cst,
192            token,
193            oauth_token: None,
194            account_id,
195            base_url: String::new(),
196            client_id: String::new(),
197            lightstreamer_endpoint: String::new(),
198            api_key: String::new(),
199            rate_limiter: Some(create_rate_limiter(
200                config.rate_limit_type,
201                Some(config.rate_limit_safety_margin),
202            )),
203            concurrent_mode: Arc::new(AtomicBool::new(false)),
204            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
205        }
206    }
207
208    /// Waits if necessary to respect rate limits before making a request
209    ///
210    /// This method will always use a rate limiter - either the one configured in the session,
211    /// or a default one if none is configured.
212    ///
213    /// This method is thread-safe and can be called from multiple threads concurrently.
214    ///
215    /// # Returns
216    /// * `Ok(())` - If the rate limit is respected
217    /// * `Err(AppError::RateLimitExceeded)` - If the rate limit has been exceeded and cannot be respected
218    pub async fn respect_rate_limit(&self) -> Result<(), AppError> {
219        // Mark that this session is being used in a concurrent context
220        self.concurrent_mode.store(true, Ordering::SeqCst);
221
222        // Get the rate limiter from the session or use a default one
223        let limiter = match &self.rate_limiter {
224            Some(limiter) => limiter.clone(),
225            None => {
226                // This should never happen since we always initialize with a default limiter,
227                // but just in case, use the global app non-trading limiter
228                debug!("No rate limiter configured in session, using default");
229                app_non_trading_limiter()
230            }
231        };
232
233        // Wait if necessary to respect the rate limit
234        limiter.wait().await;
235        Ok(())
236    }
237
238    /// Gets statistics about the current rate limit usage
239    pub async fn get_rate_limit_stats(&self) -> Option<RateLimiterStats> {
240        match &self.rate_limiter {
241            Some(limiter) => Some(limiter.get_stats().await),
242            None => None,
243        }
244    }
245
246    /// Refreshes the token timer to extend token validity
247    /// This should be called after each successful API request
248    pub fn refresh_token_timer(&self) {
249        if let Ok(mut timer) = self.token_timer.lock() {
250            timer.refresh();
251        }
252    }
253
254    /// Checks if this session is using OAuth (API v3) authentication
255    ///
256    /// # Returns
257    /// `true` if the session has OAuth tokens, `false` otherwise
258    pub fn is_oauth(&self) -> bool {
259        self.oauth_token.is_some()
260    }
261
262    /// Checks if this session is using CST/X-SECURITY-TOKEN (API v2) authentication
263    ///
264    /// # Returns
265    /// `true` if the session uses CST tokens, `false` otherwise
266    pub fn is_cst_auth(&self) -> bool {
267        !self.cst.is_empty() && !self.token.is_empty() && self.oauth_token.is_none()
268    }
269
270    /// Checks if the OAuth token needs to be refreshed
271    ///
272    /// # Arguments
273    /// * `margin_seconds` - Safety margin in seconds before actual expiry (default: 300 = 5 minutes)
274    ///
275    /// # Returns
276    /// `true` if the OAuth token is expired or will expire within the margin, `false` otherwise
277    /// Returns `false` if this is not an OAuth session
278    pub fn needs_token_refresh(&self, margin_seconds: Option<i64>) -> bool {
279        if let Some(oauth_token) = &self.oauth_token {
280            oauth_token.is_expired(margin_seconds.unwrap_or(300))
281        } else {
282            false
283        }
284    }
285
286    /// Creates a new session with OAuth authentication (API v3)
287    ///
288    /// # Arguments
289    /// * `oauth_token` - The OAuth token information
290    /// * `account_id` - Account ID associated with the session
291    /// * `client_id` - Client ID provided by the API
292    /// * `lightstreamer_endpoint` - Lightstreamer endpoint for real-time data
293    /// * `config` - Configuration for rate limiting
294    ///
295    /// # Returns
296    /// A new IgSession configured for OAuth authentication
297    pub fn from_oauth(
298        oauth_token: OAuthToken,
299        account_id: String,
300        client_id: String,
301        lightstreamer_endpoint: String,
302        config: &Config,
303    ) -> Self {
304        Self {
305            cst: String::new(),
306            token: String::new(),
307            oauth_token: Some(oauth_token),
308            account_id,
309            base_url: config.rest_api.base_url.clone(),
310            client_id,
311            lightstreamer_endpoint,
312            api_key: config.credentials.api_key.clone(),
313            rate_limiter: Some(create_rate_limiter(
314                config.rate_limit_type,
315                Some(config.rate_limit_safety_margin),
316            )),
317            concurrent_mode: Arc::new(AtomicBool::new(false)),
318            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
319        }
320    }
321}
322
323/// Trait for authenticating with the IG Markets API
324#[async_trait::async_trait]
325pub trait IgAuthenticator: Send + Sync {
326    /// Logs in to the IG Markets API and returns a new session
327    ///
328    /// Automatically selects API v2 or v3 based on configuration.
329    /// Defaults to v3 (OAuth) if not specified.
330    async fn login(&self) -> Result<IgSession, AuthError>;
331
332    /// Logs in using API v2 (CST/X-SECURITY-TOKEN authentication)
333    ///
334    /// # Returns
335    /// * `Ok(IgSession)` - A new session with CST and X-SECURITY-TOKEN
336    /// * `Err(AuthError)` - If authentication fails
337    async fn login_v2(&self) -> Result<IgSession, AuthError>;
338
339    /// Logs in using API v3 (OAuth authentication)
340    ///
341    /// # Returns
342    /// * `Ok(IgSession)` - A new session with OAuth tokens
343    /// * `Err(AuthError)` - If authentication fails
344    async fn login_v3(&self) -> Result<IgSession, AuthError>;
345
346    /// Refreshes an existing session with the IG Markets API
347    async fn refresh(&self, session: &IgSession) -> Result<IgSession, AuthError>;
348
349    /// Switches the active account for the current session
350    ///
351    /// # Arguments
352    /// * `session` - The current session
353    /// * `account_id` - The ID of the account to switch to
354    /// * `default_account` - Whether to set this account as the default (optional)
355    ///
356    /// # Returns
357    /// * A new session with the updated account ID
358    async fn switch_account(
359        &self,
360        session: &IgSession,
361        account_id: &str,
362        default_account: Option<bool>,
363    ) -> Result<IgSession, AuthError>;
364
365    /// Attempts to login and switch to the specified account, optionally setting it as the default account.
366    ///
367    /// # Arguments
368    ///
369    /// * `account_id` - A string slice that holds the ID of the account to which the session should switch.
370    /// * `default_account` - An optional boolean parameter. If `Some(true)`, the given account will be marked
371    ///   as the default account for subsequent operations. If `None` or `Some(false)`, the account will not
372    ///   be set as default.
373    ///
374    /// # Returns
375    ///
376    /// This function returns a `Result`:
377    /// * `Ok(IgSession)` - On success, contains an updated `IgSession` object representing the active session
378    ///   state after the switch.
379    /// * `Err(AuthError)` - If the operation fails, returns an `AuthError` containing details about the issue.
380    ///
381    /// # Errors
382    ///
383    /// This function can return `AuthError` in the following scenarios:
384    /// * If the provided `account_id` is invalid or does not exist.
385    /// * If there is a network issue during the login/switch process.
386    /// * If there are authentication or session-related failures.
387    ///
388    /// # Notes
389    ///
390    /// Ensure that the `account_id` is valid and accessible under the authenticated user's account scope.
391    /// Switching accounts may invalidate the previous session if the platform enforces single-session
392    /// restrictions.
393    async fn login_and_switch_account(
394        &self,
395        account_id: &str,
396        default_account: Option<bool>,
397    ) -> Result<IgSession, AuthError>;
398
399    /// Attempts to relogin (if needed) and switch to the specified account.
400    /// This method uses relogin() instead of login() to avoid unnecessary authentication
401    /// when tokens are still valid.
402    ///
403    /// # Arguments
404    /// * `session` - The current session to check for token validity
405    /// * `account_id` - The ID of the account to switch to
406    /// * `default_account` - Whether to set this account as the default (optional)
407    ///
408    /// # Returns
409    /// * `Ok(IgSession)` - On success, contains an updated session for the target account
410    /// * `Err(AuthError)` - If the operation fails
411    async fn relogin_and_switch_account(
412        &self,
413        session: &IgSession,
414        account_id: &str,
415        default_account: Option<bool>,
416    ) -> Result<IgSession, AuthError>;
417
418    /// Re-authenticates only if the current session tokens are expired or close to expiring.
419    /// This method checks the token expiration with a safety margin and only performs login
420    /// if necessary, making it more efficient than always calling login().
421    ///
422    /// # Arguments
423    /// * `session` - The current session to check for token validity
424    ///
425    /// # Returns
426    /// * `Ok(IgSession)` - Either the existing session (if tokens are still valid) or a new session (if re-login was needed)
427    /// * `Err(AuthError)` - If re-authentication fails
428    async fn relogin(&self, session: &IgSession) -> Result<IgSession, AuthError>;
429}