1use crate::constants::V2_SESSION_LIFETIME_SECS;
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use tracing::warn;
11
12#[derive(Clone, Default, Serialize, Deserialize)]
17pub struct WebsocketInfo {
18 pub server: String,
20 pub cst: Option<String>,
22 pub x_security_token: Option<String>,
24 pub account_id: String,
26}
27
28struct RedactedOption<'a>(&'a Option<String>);
31
32impl fmt::Debug for RedactedOption<'_> {
33 #[inline]
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self.0 {
36 Some(_) => f.write_str("Some(<redacted>)"),
37 None => f.write_str("None"),
38 }
39 }
40}
41
42impl fmt::Debug for WebsocketInfo {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 f.debug_struct("WebsocketInfo")
47 .field("server", &self.server)
48 .field("cst", &RedactedOption(&self.cst))
49 .field("x_security_token", &RedactedOption(&self.x_security_token))
50 .field("account_id", &self.account_id)
51 .finish()
52 }
53}
54
55impl fmt::Display for WebsocketInfo {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 write!(
60 f,
61 "WebsocketInfo {{ server: {}, cst: {:?}, x_security_token: {:?}, account_id: {} }}",
62 self.server,
63 RedactedOption(&self.cst),
64 RedactedOption(&self.x_security_token),
65 self.account_id,
66 )
67 }
68}
69
70impl WebsocketInfo {
71 #[must_use]
77 pub fn get_ws_password(&self) -> String {
78 match (&self.cst, &self.x_security_token) {
79 (Some(cst), Some(x_security_token)) => {
80 format!("CST-{}|XST-{}", cst, x_security_token)
81 }
82 _ => String::new(),
83 }
84 }
85}
86
87#[derive(Clone)]
89pub struct Session {
90 pub account_id: String,
92 pub client_id: String,
94 pub lightstreamer_endpoint: String,
96 pub cst: Option<String>,
98 pub x_security_token: Option<String>,
100 pub oauth_token: Option<OAuthToken>,
102 pub api_version: u8,
104 pub expires_at: u64,
108}
109
110impl fmt::Debug for Session {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 f.debug_struct("Session")
116 .field("account_id", &self.account_id)
117 .field("client_id", &self.client_id)
118 .field("lightstreamer_endpoint", &self.lightstreamer_endpoint)
119 .field("cst", &RedactedOption(&self.cst))
120 .field("x_security_token", &RedactedOption(&self.x_security_token))
121 .field("oauth_token", &self.oauth_token)
122 .field("api_version", &self.api_version)
123 .field("expires_at", &self.expires_at)
124 .finish()
125 }
126}
127
128impl Session {
129 #[must_use]
131 #[inline]
132 pub fn is_oauth(&self) -> bool {
133 self.oauth_token.is_some()
134 }
135
136 #[must_use]
145 #[inline]
146 pub fn is_expired(&self, margin_seconds: Option<u64>) -> bool {
147 let margin = margin_seconds.unwrap_or(60);
148 let now = u64::try_from(Utc::now().timestamp()).unwrap_or(0);
149 now >= self.expires_at.saturating_sub(margin)
152 }
153
154 #[must_use]
162 #[inline]
163 pub fn seconds_until_expiry(&self) -> u64 {
164 let now = u64::try_from(Utc::now().timestamp()).unwrap_or(0);
165 self.expires_at.saturating_sub(now)
166 }
167
168 #[must_use]
175 #[inline]
176 pub fn time_until_expiry(&self) -> std::time::Duration {
177 std::time::Duration::from_secs(self.seconds_until_expiry())
178 }
179
180 #[must_use]
185 #[inline]
186 pub fn needs_token_refresh(&self, margin_seconds: Option<u64>) -> bool {
187 self.is_expired(margin_seconds)
188 }
189
190 #[must_use]
195 pub fn get_websocket_info(&self) -> WebsocketInfo {
196 let server = if self.lightstreamer_endpoint.starts_with("http://")
198 || self.lightstreamer_endpoint.starts_with("https://")
199 {
200 format!("{}/lightstreamer", self.lightstreamer_endpoint)
201 } else {
202 format!("https://{}/lightstreamer", self.lightstreamer_endpoint)
203 };
204
205 WebsocketInfo {
206 server,
207 cst: self.cst.clone(),
208 x_security_token: self.x_security_token.clone(),
209 account_id: self.account_id.clone(),
210 }
211 }
212}
213
214impl From<SessionResponse> for Session {
215 fn from(v: SessionResponse) -> Self {
216 v.get_session()
217 }
218}
219
220#[must_use]
228#[inline]
229fn is_past_effective_expiry(
230 created_at: chrono::DateTime<Utc>,
231 lifetime_secs: i64,
232 margin_secs: i64,
233 now: chrono::DateTime<Utc>,
234) -> bool {
235 let effective = chrono::Duration::try_seconds(lifetime_secs)
236 .and_then(|lifetime| created_at.checked_add_signed(lifetime))
237 .and_then(|expiry| {
238 chrono::Duration::try_seconds(margin_secs).and_then(|m| expiry.checked_sub_signed(m))
239 });
240 match effective {
241 Some(effective_expiry) => effective_expiry <= now,
242 None => true,
244 }
245}
246
247#[derive(Debug, Clone, Deserialize, Serialize)]
251#[serde(untagged)]
252pub enum SessionResponse {
253 V3(V3Response),
255 V2(V2Response),
257}
258
259impl SessionResponse {
260 #[must_use]
262 #[inline]
263 pub fn is_v3(&self) -> bool {
264 matches!(self, SessionResponse::V3(_))
265 }
266
267 #[must_use]
269 #[inline]
270 pub fn is_v2(&self) -> bool {
271 matches!(self, SessionResponse::V2(_))
272 }
273
274 #[must_use]
276 pub fn get_session(&self) -> Session {
277 match self {
278 SessionResponse::V3(v) => Session {
279 account_id: v.account_id.clone(),
280 client_id: v.client_id.clone(),
281 lightstreamer_endpoint: v.lightstreamer_endpoint.clone(),
282 cst: None,
283 x_security_token: None,
284 oauth_token: Some(v.oauth_token.clone()),
285 api_version: 3,
286 expires_at: v.oauth_token.expire_at(1),
287 },
288 SessionResponse::V2(v) => {
289 let (cst, x_security_token) = match v.security_headers.as_ref() {
290 Some(headers) => (
291 Some(headers.cst.clone()),
292 Some(headers.x_security_token.clone()),
293 ),
294 None => (None, None),
295 };
296 let lifetime = v.expires_in.unwrap_or(V2_SESSION_LIFETIME_SECS);
302 let created = u64::try_from(v.created_at.timestamp()).unwrap_or(0);
303 let expires_at = created.saturating_add(lifetime);
304 Session {
305 account_id: v.current_account_id.clone(),
306 client_id: v.client_id.clone(),
307 lightstreamer_endpoint: v.lightstreamer_endpoint.clone(),
308 cst,
309 x_security_token,
310 oauth_token: None,
311 api_version: 2,
312 expires_at,
313 }
314 }
315 }
316 }
317 pub fn get_session_v2(&mut self, headers: &SecurityHeaders) -> Session {
322 match self {
323 SessionResponse::V3(_) => {
324 warn!("Returing V3 session from V2 headers - this may be unexpected");
325 self.get_session()
326 }
327 SessionResponse::V2(v) => {
328 v.set_security_headers(headers);
329 v.expires_in = Some(V2_SESSION_LIFETIME_SECS);
330 self.get_session()
331 }
332 }
333 }
334
335 #[must_use]
340 #[inline]
341 pub fn is_expired(&self, margin_seconds: u64) -> bool {
342 match self {
343 SessionResponse::V3(v) => v.oauth_token.is_expired(margin_seconds),
344 SessionResponse::V2(v) => v.is_expired(margin_seconds),
345 }
346 }
347}
348
349#[derive(Debug, Clone, Deserialize, Serialize)]
351#[serde(rename_all = "camelCase")]
352pub struct V3Response {
353 pub client_id: String,
355 pub account_id: String,
357 pub timezone_offset: i32,
359 pub lightstreamer_endpoint: String,
361 pub oauth_token: OAuthToken,
363}
364
365#[derive(serde::Deserialize, serde::Serialize, Clone)]
371pub struct OAuthToken {
372 pub access_token: String,
374 pub refresh_token: String,
376 pub scope: String,
378 pub token_type: String,
380 pub expires_in: String,
382 #[serde(skip, default = "chrono::Utc::now")]
384 pub created_at: chrono::DateTime<Utc>,
385}
386
387impl fmt::Debug for OAuthToken {
390 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391 f.debug_struct("OAuthToken")
392 .field("access_token", &"<redacted>")
393 .field("refresh_token", &"<redacted>")
394 .field("scope", &self.scope)
395 .field("token_type", &self.token_type)
396 .field("expires_in", &self.expires_in)
397 .field("created_at", &self.created_at)
398 .finish()
399 }
400}
401
402impl OAuthToken {
403 #[must_use]
412 #[inline]
413 fn expires_in_secs(&self) -> i64 {
414 self.expires_in.parse::<i64>().unwrap_or_else(|_| {
415 static MALFORMED_EXPIRES_IN_WARNED: std::sync::Once = std::sync::Once::new();
416 MALFORMED_EXPIRES_IN_WARNED.call_once(|| {
417 warn!(
418 expires_in = %self.expires_in,
419 "malformed expires_in; treating token as expired (further occurrences suppressed)"
420 );
421 });
422 0
423 })
424 }
425
426 #[must_use]
434 #[inline]
435 pub fn is_expired(&self, margin_seconds: u64) -> bool {
436 let margin = i64::try_from(margin_seconds).unwrap_or(i64::MAX);
437 is_past_effective_expiry(self.created_at, self.expires_in_secs(), margin, Utc::now())
438 }
439
440 #[must_use]
450 pub fn expire_at(&self, margin_seconds: i64) -> u64 {
451 let effective = chrono::Duration::try_seconds(self.expires_in_secs())
452 .and_then(|lifetime| self.created_at.checked_add_signed(lifetime))
453 .and_then(|expiry| {
454 chrono::Duration::try_seconds(margin_seconds)
455 .and_then(|m| expiry.checked_sub_signed(m))
456 });
457
458 match effective {
459 Some(effective_expiry) => u64::try_from(effective_expiry.timestamp()).unwrap_or(0),
460 None => 0,
461 }
462 }
463}
464
465#[derive(Debug, Clone, Deserialize, Serialize)]
467#[serde(rename_all = "camelCase")]
468pub struct V2Response {
469 pub account_type: String,
471 pub account_info: AccountInfo,
473 pub currency_iso_code: String,
475 pub currency_symbol: String,
477 pub current_account_id: String,
479 pub lightstreamer_endpoint: String,
481 pub accounts: Vec<Account>,
483 pub client_id: String,
485 pub timezone_offset: i32,
487 pub has_active_demo_accounts: bool,
489 pub has_active_live_accounts: bool,
491 pub trailing_stops_enabled: bool,
493 pub rerouting_environment: Option<String>,
495 pub dealing_enabled: bool,
497 #[serde(skip)]
499 pub security_headers: Option<SecurityHeaders>,
500 #[serde(skip)]
502 pub expires_in: Option<u64>,
503 #[serde(skip, default = "chrono::Utc::now")]
505 pub created_at: chrono::DateTime<Utc>,
506}
507
508impl V2Response {
509 pub fn set_security_headers(&mut self, headers: &SecurityHeaders) {
514 self.security_headers = Some(headers.clone());
515 }
516
517 #[must_use]
525 pub fn is_expired(&self, margin_seconds: u64) -> bool {
526 match self.expires_in {
527 Some(expires_in) => {
528 let lifetime = i64::try_from(expires_in).unwrap_or(i64::MAX);
529 let margin = i64::try_from(margin_seconds).unwrap_or(i64::MAX);
530 is_past_effective_expiry(self.created_at, lifetime, margin, Utc::now())
531 }
532 None => true,
534 }
535 }
536}
537
538#[derive(Clone, Deserialize, Serialize)]
540pub struct SecurityHeaders {
541 pub cst: String,
543 pub x_security_token: String,
545 pub x_ig_api_key: String,
547}
548
549impl fmt::Debug for SecurityHeaders {
552 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553 f.debug_struct("SecurityHeaders")
554 .field("cst", &"<redacted>")
555 .field("x_security_token", &"<redacted>")
556 .field("x_ig_api_key", &"<redacted>")
557 .finish()
558 }
559}
560
561#[derive(Debug, Clone, Deserialize, Serialize)]
563#[serde(rename_all = "camelCase")]
564pub struct AccountInfo {
565 pub balance: f64,
567 pub deposit: f64,
569 pub profit_loss: f64,
571 pub available: f64,
573}
574
575#[derive(Debug, Clone, Deserialize, Serialize)]
577#[serde(rename_all = "camelCase")]
578pub struct Account {
579 pub account_id: String,
581 pub account_name: String,
583 pub preferred: bool,
585 pub account_type: String,
587}
588
589#[cfg(test)]
590mod redaction_tests {
591 use super::*;
592
593 #[test]
594 fn test_oauth_token_debug_redacts_tokens() {
595 let token = OAuthToken {
596 access_token: "SECRET-ACCESS-VALUE".to_string(),
597 refresh_token: "SECRET-REFRESH-VALUE".to_string(),
598 scope: "read write".to_string(),
599 token_type: "Bearer".to_string(),
600 expires_in: "60".to_string(),
601 created_at: Utc::now(),
602 };
603 let rendered = format!("{token:?}");
604
605 assert!(!rendered.contains("SECRET-ACCESS-VALUE"));
606 assert!(!rendered.contains("SECRET-REFRESH-VALUE"));
607 assert!(rendered.contains("<redacted>"));
608 assert!(rendered.contains("Bearer"));
610 assert!(rendered.contains("read write"));
611 }
612
613 #[test]
614 fn test_security_headers_debug_redacts_tokens() {
615 let headers = SecurityHeaders {
616 cst: "SECRET-CST-VALUE".to_string(),
617 x_security_token: "SECRET-XST-VALUE".to_string(),
618 x_ig_api_key: "SECRET-API-KEY-VALUE".to_string(),
619 };
620 let rendered = format!("{headers:?}");
621
622 assert!(!rendered.contains("SECRET-CST-VALUE"));
623 assert!(!rendered.contains("SECRET-XST-VALUE"));
624 assert!(!rendered.contains("SECRET-API-KEY-VALUE"));
625 assert!(rendered.contains("<redacted>"));
626 }
627}
628
629#[cfg(test)]
630mod expiry_tests {
631 use super::*;
632
633 fn oauth_token(expires_in: &str) -> OAuthToken {
634 OAuthToken {
635 access_token: "ACCESS".to_string(),
636 refresh_token: "REFRESH".to_string(),
637 scope: "read write".to_string(),
638 token_type: "Bearer".to_string(),
639 expires_in: expires_in.to_string(),
640 created_at: Utc::now(),
641 }
642 }
643
644 #[test]
645 fn test_oauth_token_is_expired_malformed_expires_in_treated_expired() {
646 let token = oauth_token("not-a-number");
649 assert!(token.is_expired(60));
650 }
651
652 #[test]
653 fn test_oauth_token_is_expired_fresh_token_not_expired() {
654 let token = oauth_token("3600");
656 assert!(!token.is_expired(60));
657 }
658
659 #[test]
660 fn test_oauth_token_expire_at_malformed_expires_in_saturates() {
661 let token = oauth_token("garbage");
664 let now = Utc::now().timestamp();
665 let expires_at = token.expire_at(1);
666 assert!(expires_at <= u64::try_from(now).unwrap_or(0));
668 }
669
670 fn v2_response(expires_in: Option<u64>) -> V2Response {
671 V2Response {
672 account_type: "CFD".to_string(),
673 account_info: AccountInfo {
674 balance: 0.0,
675 deposit: 0.0,
676 profit_loss: 0.0,
677 available: 0.0,
678 },
679 currency_iso_code: "EUR".to_string(),
680 currency_symbol: "E".to_string(),
681 current_account_id: "ACC123".to_string(),
682 lightstreamer_endpoint: "https://demo-apd.marketdatasystems.com".to_string(),
683 accounts: Vec::new(),
684 client_id: "CLIENT1".to_string(),
685 timezone_offset: 1,
686 has_active_demo_accounts: true,
687 has_active_live_accounts: false,
688 trailing_stops_enabled: false,
689 rerouting_environment: None,
690 dealing_enabled: true,
691 security_headers: None,
692 expires_in,
693 created_at: Utc::now(),
694 }
695 }
696
697 #[test]
698 fn test_v2_session_expires_at_derived_from_created_at_plus_lifetime() {
699 let resp = v2_response(None);
702 let created = u64::try_from(resp.created_at.timestamp()).unwrap_or(0);
703 let session = SessionResponse::V2(resp).get_session();
704 assert_eq!(session.expires_at, created + V2_SESSION_LIFETIME_SECS);
705 }
706
707 #[test]
708 fn test_v2_response_is_expired_none_expires_in_treated_expired() {
709 let resp = v2_response(None);
711 assert!(resp.is_expired(300));
712 }
713
714 #[test]
715 fn test_v2_response_is_expired_fresh_lifetime_not_expired() {
716 let resp = v2_response(Some(V2_SESSION_LIFETIME_SECS));
717 assert!(!resp.is_expired(300));
718 }
719}
720
721#[cfg(test)]
722mod session_response_round_trip_tests {
723 use super::*;
724
725 const V2_DEMO_BODY: &str = r#"{"accountType":"CFD","accountInfo":{"balance":21065.86,"deposit":3033.31,"profitLoss":-285.27,"available":16659.01},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"ZZZZZ","lightstreamerEndpoint":"https://demo-apd.marketdatasystems.com","accounts":[{"accountId":"Z405P5","accountName":"Turbo24","preferred":false,"accountType":"PHYSICAL"},{"accountId":"ZHJ5N","accountName":"DEMO_A","preferred":false,"accountType":"CFD"},{"accountId":"ZZZZZ","accountName":"Opciones","preferred":true,"accountType":"CFD"}],"clientId":"101290216","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
730
731 const V2_ALT_BODY: &str = r#"{"accountType":"CFD","accountInfo":{"balance":18791.56,"deposit":3300.18,"profitLoss":187.42,"available":14952.68},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"BS0Y3","lightstreamerEndpoint":"https://apd.marketdatasystems.com","accounts":[{"accountId":"BS0Y3","accountName":"Opciones Prod","preferred":true,"accountType":"CFD"},{"accountId":"BSI1I","accountName":"Barreras y Opciones","preferred":false,"accountType":"CFD"},{"accountId":"BSU96","accountName":"Turbos","preferred":false,"accountType":"PHYSICAL"},{"accountId":"BTCKN","accountName":"CFD","preferred":false,"accountType":"CFD"},{"accountId":"BXNIZ","accountName":"Principal","preferred":false,"accountType":"CFD"}],"clientId":"102828353","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
734
735 const V3_BODY: &str = r#"{"clientId":"101290216","accountId":"ZZZZZ","timezoneOffset":1,"lightstreamerEndpoint":"https://demo-apd.marketdatasystems.com","oauthToken":{"access_token":"placeholder-access","refresh_token":"placeholder-refresh","scope":"profile","token_type":"Bearer","expires_in":"60"}}"#;
740
741 #[test]
742 fn test_session_response_v2_demo_body_parses_as_v2() {
743 let response: SessionResponse =
744 serde_json::from_str(V2_DEMO_BODY).expect("v2 demo body should deserialize");
745 assert!(response.is_v2());
746 assert!(!response.is_v3());
747 }
748
749 #[test]
750 fn test_v2_response_demo_body_fields_round_trip() {
751 let response: V2Response =
752 serde_json::from_str(V2_DEMO_BODY).expect("v2 demo body should deserialize");
753 assert_eq!(response.account_type, "CFD");
754 assert_eq!(response.current_account_id, "ZZZZZ");
755 assert_eq!(response.accounts.len(), 3);
756 assert_eq!(response.currency_iso_code, "EUR");
757 }
758
759 #[test]
760 fn test_v2_response_alt_body_reads_current_account_id() {
761 let response: SessionResponse =
762 serde_json::from_str(V2_ALT_BODY).expect("v2 alt body should deserialize");
763 assert!(response.is_v2());
764
765 let response: V2Response =
766 serde_json::from_str(V2_ALT_BODY).expect("v2 alt body should deserialize");
767 assert_eq!(response.account_type, "CFD");
768 assert_eq!(response.current_account_id, "BS0Y3");
769 assert_eq!(response.accounts.len(), 5);
770 }
771
772 #[test]
773 fn test_session_response_v3_body_parses_as_v3() {
774 let response: SessionResponse =
775 serde_json::from_str(V3_BODY).expect("v3 body should deserialize");
776 assert!(response.is_v3());
777 assert!(!response.is_v2());
778 }
779
780 #[test]
781 fn test_v3_response_body_fields_round_trip() {
782 let response: V3Response =
783 serde_json::from_str(V3_BODY).expect("v3 body should deserialize");
784 assert_eq!(response.client_id, "101290216");
785 assert_eq!(response.account_id, "ZZZZZ");
786 assert_eq!(response.timezone_offset, 1);
787 assert_eq!(response.oauth_token.token_type, "Bearer");
788 assert_eq!(response.oauth_token.expires_in, "60");
789 assert!(!response.oauth_token.is_expired(10));
791 }
792}