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    /// Creates a new session with OAuth authentication (API v3)
271    ///
272    /// # Arguments
273    /// * `oauth_token` - The OAuth token information
274    /// * `account_id` - Account ID associated with the session
275    /// * `client_id` - Client ID provided by the API
276    /// * `lightstreamer_endpoint` - Lightstreamer endpoint for real-time data
277    /// * `config` - Configuration for rate limiting
278    ///
279    /// # Returns
280    /// A new IgSession configured for OAuth authentication
281    pub fn from_oauth(
282        oauth_token: OAuthToken,
283        account_id: String,
284        client_id: String,
285        lightstreamer_endpoint: String,
286        config: &Config,
287    ) -> Self {
288        Self {
289            cst: String::new(),
290            token: String::new(),
291            oauth_token: Some(oauth_token),
292            account_id,
293            base_url: config.rest_api.base_url.clone(),
294            client_id,
295            lightstreamer_endpoint,
296            api_key: config.credentials.api_key.clone(),
297            rate_limiter: Some(create_rate_limiter(
298                config.rate_limit_type,
299                Some(config.rate_limit_safety_margin),
300            )),
301            concurrent_mode: Arc::new(AtomicBool::new(false)),
302            token_timer: Arc::new(Mutex::new(TokenTimer::new())),
303        }
304    }
305}
306
307/// Trait for authenticating with the IG Markets API
308#[async_trait::async_trait]
309pub trait IgAuthenticator: Send + Sync {
310    /// Logs in to the IG Markets API and returns a new session
311    ///
312    /// Automatically selects API v2 or v3 based on configuration.
313    /// Defaults to v3 (OAuth) if not specified.
314    async fn login(&self) -> Result<IgSession, AuthError>;
315
316    /// Logs in using API v2 (CST/X-SECURITY-TOKEN authentication)
317    ///
318    /// # Returns
319    /// * `Ok(IgSession)` - A new session with CST and X-SECURITY-TOKEN
320    /// * `Err(AuthError)` - If authentication fails
321    async fn login_v2(&self) -> Result<IgSession, AuthError>;
322
323    /// Logs in using API v3 (OAuth authentication)
324    ///
325    /// # Returns
326    /// * `Ok(IgSession)` - A new session with OAuth tokens
327    /// * `Err(AuthError)` - If authentication fails
328    async fn login_v3(&self) -> Result<IgSession, AuthError>;
329
330    /// Refreshes an existing session with the IG Markets API
331    async fn refresh(&self, session: &IgSession) -> Result<IgSession, AuthError>;
332
333    /// Switches the active account for the current session
334    ///
335    /// # Arguments
336    /// * `session` - The current session
337    /// * `account_id` - The ID of the account to switch to
338    /// * `default_account` - Whether to set this account as the default (optional)
339    ///
340    /// # Returns
341    /// * A new session with the updated account ID
342    async fn switch_account(
343        &self,
344        session: &IgSession,
345        account_id: &str,
346        default_account: Option<bool>,
347    ) -> Result<IgSession, AuthError>;
348
349    /// Attempts to login and switch to the specified account, optionally setting it as the default account.
350    ///
351    /// # Arguments
352    ///
353    /// * `account_id` - A string slice that holds the ID of the account to which the session should switch.
354    /// * `default_account` - An optional boolean parameter. If `Some(true)`, the given account will be marked
355    ///   as the default account for subsequent operations. If `None` or `Some(false)`, the account will not
356    ///   be set as default.
357    ///
358    /// # Returns
359    ///
360    /// This function returns a `Result`:
361    /// * `Ok(IgSession)` - On success, contains an updated `IgSession` object representing the active session
362    ///   state after the switch.
363    /// * `Err(AuthError)` - If the operation fails, returns an `AuthError` containing details about the issue.
364    ///
365    /// # Errors
366    ///
367    /// This function can return `AuthError` in the following scenarios:
368    /// * If the provided `account_id` is invalid or does not exist.
369    /// * If there is a network issue during the login/switch process.
370    /// * If there are authentication or session-related failures.
371    ///
372    /// # Notes
373    ///
374    /// Ensure that the `account_id` is valid and accessible under the authenticated user's account scope.
375    /// Switching accounts may invalidate the previous session if the platform enforces single-session
376    /// restrictions.
377    async fn login_and_switch_account(
378        &self,
379        account_id: &str,
380        default_account: Option<bool>,
381    ) -> Result<IgSession, AuthError>;
382
383    /// Attempts to relogin (if needed) and switch to the specified account.
384    /// This method uses relogin() instead of login() to avoid unnecessary authentication
385    /// when tokens are still valid.
386    ///
387    /// # Arguments
388    /// * `session` - The current session to check for token validity
389    /// * `account_id` - The ID of the account to switch to
390    /// * `default_account` - Whether to set this account as the default (optional)
391    ///
392    /// # Returns
393    /// * `Ok(IgSession)` - On success, contains an updated session for the target account
394    /// * `Err(AuthError)` - If the operation fails
395    async fn relogin_and_switch_account(
396        &self,
397        session: &IgSession,
398        account_id: &str,
399        default_account: Option<bool>,
400    ) -> Result<IgSession, AuthError>;
401
402    /// Re-authenticates only if the current session tokens are expired or close to expiring.
403    /// This method checks the token expiration with a safety margin and only performs login
404    /// if necessary, making it more efficient than always calling login().
405    ///
406    /// # Arguments
407    /// * `session` - The current session to check for token validity
408    ///
409    /// # Returns
410    /// * `Ok(IgSession)` - Either the existing session (if tokens are still valid) or a new session (if re-login was needed)
411    /// * `Err(AuthError)` - If re-authentication fails
412    async fn relogin(&self, session: &IgSession) -> Result<IgSession, AuthError>;
413}