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}