1use reqwest::{Method, RequestBuilder, StatusCode};
2use serde::{Serialize, de::DeserializeOwned};
3use std::sync::RwLock;
4use std::time::{Duration, Instant};
5use url::Url;
6
7use crate::{
8 config::{AuthenticationVersion, ClientConfig, Credentials},
9 error::{IgError, IgResult},
10 streaming::IgStreamingClient,
11 types::{
12 AccountsResponse, ActivityHistoryResponse, CategoryInstrumentsResponse,
13 CreatePositionRequest, CreateWorkingOrderRequest, DealConfirmation, DealReferenceResponse,
14 HistoricalPricesQuery, HistoricalPricesResponse, InstrumentCategoriesResponse,
15 LoginResponse, MarketDetails, MarketsResponse, OAuthToken, PositionsResponse,
16 SwitchAccountResponse, UpdateWorkingOrderRequest, V3LoginResponse, WorkingOrdersResponse,
17 },
18};
19
20#[derive(Debug, Clone)]
21struct SessionTokens {
22 cst: String,
23 x_security_token: String,
24 account_id: String,
25 lightstreamer_endpoint: String,
26}
27
28#[derive(Debug, Clone)]
29struct OAuthSession {
30 access_token: String,
31 refresh_token: String,
32 account_id: String,
33 lightstreamer_endpoint: String,
34 expires_at: Instant,
35}
36
37#[derive(Debug, Clone)]
38enum Session {
39 V2(SessionTokens),
40 V3(OAuthSession),
41}
42
43const TOKEN_REFRESH_THRESHOLD: Duration = Duration::from_secs(10);
44
45#[derive(Debug)]
51pub struct IgClient {
52 http: reqwest::Client,
53 config: ClientConfig,
54 base_url: Url,
55 session: RwLock<Option<Session>>,
56}
57
58impl IgClient {
59 pub fn new(config: ClientConfig) -> IgResult<Self> {
61 let base_url = Url::parse(config.environment.rest_base()).map_err(|error| {
62 IgError::InvalidConfiguration(format!("invalid REST base URL: {error}"))
63 })?;
64 let mut http = reqwest::Client::builder().timeout(config.timeout);
65 if let Some(proxy) = &config.proxy {
66 http = http.proxy(reqwest::Proxy::all(proxy).map_err(|error| {
67 IgError::InvalidConfiguration(format!("invalid proxy URL: {error}"))
68 })?);
69 }
70 Ok(Self {
71 http: http.build()?,
72 config,
73 base_url,
74 session: RwLock::new(None),
75 })
76 }
77
78 pub async fn login(&self) -> IgResult<LoginResponse> {
80 match &self.config.authentication {
81 AuthenticationVersion::V2 => self.login_v2().await,
82 AuthenticationVersion::V3 { account_id } => self.login_v3(account_id).await,
83 }
84 }
85
86 pub async fn login_v2(&self) -> IgResult<LoginResponse> {
88 #[derive(Serialize)]
89 struct LoginRequest<'a> {
90 identifier: &'a str,
91 password: &'a str,
92 }
93
94 let credentials = self.credentials()?;
95 let response = self
96 .request(Method::POST, "session", 2, false)?
97 .json(&LoginRequest {
98 identifier: credentials.identifier(),
99 password: credentials.password(),
100 })
101 .send()
102 .await?;
103 let (tokens, login) = self.decode_login(response).await?;
104 *self.session.write().map_err(|_| IgError::MissingSession)? = Some(Session::V2(tokens));
105 Ok(login)
106 }
107
108 pub async fn login_v3(&self, account_id: &str) -> IgResult<LoginResponse> {
110 #[derive(Serialize)]
111 #[serde(rename_all = "camelCase")]
112 struct LoginRequest<'a> {
113 identifier: &'a str,
114 password: &'a str,
115 account_id: &'a str,
116 }
117
118 if account_id.is_empty() {
119 return Err(IgError::InvalidConfiguration(
120 "v3 authentication requires a non-empty account ID".to_owned(),
121 ));
122 }
123 let credentials = self.credentials()?;
124 let response = self
125 .request(Method::POST, "session", 3, false)?
126 .header("IG-ACCOUNT-ID", account_id)
127 .json(&LoginRequest {
128 identifier: credentials.identifier(),
129 password: credentials.password(),
130 account_id,
131 })
132 .send()
133 .await?;
134 let response = Self::ensure_success(response).await?;
135 let login: V3LoginResponse = response.json().await?;
136 let oauth = login.oauth_token.ok_or_else(|| {
137 IgError::InvalidConfiguration(
138 "IG v3 login response did not contain an OAuth token".to_owned(),
139 )
140 })?;
141 let selected_account = login
142 .account_id
143 .clone()
144 .unwrap_or_else(|| account_id.to_owned());
145 let result = LoginResponse {
146 current_account_id: login.current_account_id.or(login.account_id),
147 lightstreamer_endpoint: login.lightstreamer_endpoint,
148 client_id: login.client_id,
149 currency_iso_code: login.currency_iso_code,
150 dealing_enabled: login.dealing_enabled,
151 };
152 *self.session.write().map_err(|_| IgError::MissingSession)? =
153 Some(Session::V3(oauth_session(
154 oauth,
155 selected_account,
156 result.lightstreamer_endpoint.clone(),
157 )));
158 Ok(result)
159 }
160
161 pub async fn logout(&self) -> IgResult<()> {
163 self.refresh_oauth_if_needed().await?;
164 let response = self
165 .request(Method::DELETE, "session", 1, true)?
166 .send()
167 .await?;
168 Self::ensure_success(response).await?;
169 *self.session.write().map_err(|_| IgError::MissingSession)? = None;
170 Ok(())
171 }
172
173 pub async fn accounts(&self) -> IgResult<AccountsResponse> {
175 self.get("accounts", 1).await
176 }
177
178 pub async fn session_details(&self) -> IgResult<LoginResponse> {
180 self.get("session", 1).await
181 }
182
183 pub async fn switch_account(&self, account_id: &str) -> IgResult<SwitchAccountResponse> {
190 #[derive(Serialize)]
191 #[serde(rename_all = "camelCase")]
192 struct SwitchRequest<'a> {
193 account_id: &'a str,
194 default_account: bool,
195 }
196
197 if account_id.trim().is_empty() {
198 return Err(IgError::InvalidConfiguration(
199 "account switch requires a non-empty account ID".to_owned(),
200 ));
201 }
202 let response = self
203 .request(Method::PUT, "session", 1, true)?
204 .json(&SwitchRequest {
205 account_id,
206 default_account: false,
207 })
208 .send()
209 .await?;
210 let response = Self::ensure_success(response).await?;
211 let next_token = response
212 .headers()
213 .get("X-SECURITY-TOKEN")
214 .and_then(|value| value.to_str().ok())
215 .map(str::to_owned);
216 let result: SwitchAccountResponse = response.json().await?;
217 let mut session = self.session.write().map_err(|_| IgError::MissingSession)?;
218 let Some(Session::V2(tokens)) = session.as_mut() else {
219 return Err(IgError::InvalidConfiguration(
220 "account switching requires a V2 CST/XST session".to_owned(),
221 ));
222 };
223 tokens.account_id = account_id.to_owned();
224 if let Some(token) = next_token {
225 tokens.x_security_token = token;
226 }
227 Ok(result)
228 }
229
230 pub async fn positions(&self) -> IgResult<PositionsResponse> {
232 self.get("positions", 2).await
233 }
234
235 pub async fn market(&self, epic: &str) -> IgResult<MarketDetails> {
237 self.get(&format!("markets/{}", encode_path_segment(epic)), 3)
238 .await
239 }
240
241 pub async fn search_markets(&self, query: &str) -> IgResult<MarketsResponse> {
243 self.refresh_oauth_if_needed().await?;
244 let mut url = self.url("markets")?;
245 url.query_pairs_mut().append_pair("searchTerm", query);
246 self.send_json(self.request_url(Method::GET, url, 1, true)?)
247 .await
248 }
249
250 pub async fn instrument_categories(&self) -> IgResult<InstrumentCategoriesResponse> {
252 self.get("categories", 1).await
253 }
254
255 pub async fn category_instruments(
257 &self,
258 category_id: &str,
259 page_number: u32,
260 page_size: u16,
261 ) -> IgResult<CategoryInstrumentsResponse> {
262 let category_id = category_id.trim();
263 if category_id.is_empty() {
264 return Err(IgError::InvalidConfiguration(
265 "IG instrument category ID must not be empty".into(),
266 ));
267 }
268 self.refresh_oauth_if_needed().await?;
269 let mut url = self.url(&format!(
270 "categories/{}/instruments",
271 encode_path_segment(category_id)
272 ))?;
273 url.query_pairs_mut()
274 .append_pair("pageSize", &page_size.clamp(1, 1_000).to_string())
275 .append_pair("pageNumber", &page_number.to_string());
276 self.send_json(self.request_url(Method::GET, url, 1, true)?)
277 .await
278 }
279
280 pub async fn account_activity(
285 &self,
286 from: &str,
287 to: &str,
288 page_size: u16,
289 ) -> IgResult<ActivityHistoryResponse> {
290 self.refresh_oauth_if_needed().await?;
291 let mut url = self.url("history/activity")?;
292 url.query_pairs_mut()
293 .append_pair("from", from)
294 .append_pair("to", to)
295 .append_pair("detailed", "true")
296 .append_pair("pageSize", &page_size.clamp(10, 500).to_string());
297 self.send_json(self.request_url(Method::GET, url, 3, true)?)
298 .await
299 }
300
301 pub async fn account_activity_next(&self, next: &str) -> IgResult<ActivityHistoryResponse> {
304 self.refresh_oauth_if_needed().await?;
305 let next = next.trim();
306 let (path, query) = next.split_once('?').unwrap_or((next, ""));
307 let path = path.trim_start_matches('/');
308 let path = path.strip_prefix("gateway/deal/").unwrap_or(path);
309 if path != "history/activity" {
310 return Err(IgError::InvalidConfiguration(
311 "IG activity next-page link has an unexpected path".into(),
312 ));
313 }
314 let mut url = self.url(path)?;
315 if !query.is_empty() {
316 url.set_query(Some(query));
317 }
318 self.send_json(self.request_url(Method::GET, url, 3, true)?)
319 .await
320 }
321
322 pub async fn historical_prices(
324 &self,
325 epic: &str,
326 query: HistoricalPricesQuery<'_>,
327 ) -> IgResult<HistoricalPricesResponse> {
328 self.refresh_oauth_if_needed().await?;
329 let mut url = self.url(&format!("prices/{}", encode_path_segment(epic)))?;
330 {
331 let mut pairs = url.query_pairs_mut();
332 pairs.append_pair("resolution", query.resolution);
333 if let Some(value) = query.from {
334 pairs.append_pair("from", value);
335 }
336 if let Some(value) = query.to {
337 pairs.append_pair("to", value);
338 }
339 if let Some(value) = query.max {
340 pairs.append_pair("max", &value.to_string());
341 }
342 }
343 self.send_json(self.request_url(Method::GET, url, 3, true)?)
344 .await
345 }
346
347 pub async fn create_position(
349 &self,
350 request: &CreatePositionRequest,
351 ) -> IgResult<DealReferenceResponse> {
352 self.refresh_oauth_if_needed().await?;
353 let response = self
354 .request(Method::POST, "positions/otc", 2, true)?
355 .json(request)
356 .send()
357 .await?;
358 Self::decode_json(response).await
359 }
360
361 pub async fn deal_confirmation(&self, deal_reference: &str) -> IgResult<DealConfirmation> {
365 self.get(
366 &format!("confirms/{}", encode_path_segment(deal_reference)),
367 1,
368 )
369 .await
370 }
371
372 pub async fn working_orders(&self) -> IgResult<WorkingOrdersResponse> {
374 self.get("workingorders", 2).await
375 }
376
377 pub async fn create_working_order(
380 &self,
381 request: &CreateWorkingOrderRequest,
382 ) -> IgResult<DealReferenceResponse> {
383 self.mutate_json(Method::POST, "workingorders/otc", 2, request)
384 .await
385 }
386
387 pub async fn update_working_order(
389 &self,
390 deal_id: &str,
391 request: &UpdateWorkingOrderRequest,
392 ) -> IgResult<DealReferenceResponse> {
393 self.mutate_json(
394 Method::PUT,
395 &format!("workingorders/otc/{}", encode_path_segment(deal_id)),
396 2,
397 request,
398 )
399 .await
400 }
401
402 pub async fn delete_working_order(&self, deal_id: &str) -> IgResult<DealReferenceResponse> {
404 self.refresh_oauth_if_needed().await?;
405 let response = self
406 .request(
407 Method::DELETE,
408 &format!("workingorders/otc/{}", encode_path_segment(deal_id)),
409 2,
410 true,
411 )?
412 .send()
413 .await?;
414 Self::decode_json(response).await
415 }
416
417 pub async fn connect_streaming(&self) -> IgResult<IgStreamingClient> {
419 enum StreamingCredentials {
420 Ready(SessionTokens),
421 Fetch {
422 account_id: String,
423 endpoint: String,
424 },
425 }
426 let credentials = {
427 let session = self.session.read().map_err(|_| IgError::MissingSession)?;
428 match session.as_ref() {
429 Some(Session::V2(tokens)) => StreamingCredentials::Ready(tokens.clone()),
430 Some(Session::V3(oauth)) => StreamingCredentials::Fetch {
431 account_id: oauth.account_id.clone(),
432 endpoint: oauth.lightstreamer_endpoint.clone(),
433 },
434 None => return Err(IgError::MissingSession),
435 }
436 };
437 let tokens = match credentials {
438 StreamingCredentials::Ready(tokens) => tokens,
439 StreamingCredentials::Fetch {
440 account_id,
441 endpoint,
442 } => self.fetch_streaming_tokens(account_id, endpoint).await?,
443 };
444 IgStreamingClient::connect(
445 &tokens.lightstreamer_endpoint,
446 tokens.account_id,
447 tokens.cst,
448 tokens.x_security_token,
449 )
450 .await
451 }
452
453 async fn fetch_streaming_tokens(
454 &self,
455 account_id: String,
456 lightstreamer_endpoint: String,
457 ) -> IgResult<SessionTokens> {
458 self.refresh_oauth_if_needed().await?;
459 let mut url = self.url("session")?;
460 url.query_pairs_mut()
461 .append_pair("fetchSessionTokens", "true");
462 let response = self.request_url(Method::GET, url, 1, true)?.send().await?;
463 let response = Self::ensure_success(response).await?;
464 let cst = response
465 .headers()
466 .get("CST")
467 .and_then(|value| value.to_str().ok())
468 .ok_or(IgError::MissingToken("CST"))?
469 .to_owned();
470 let x_security_token = response
471 .headers()
472 .get("X-SECURITY-TOKEN")
473 .and_then(|value| value.to_str().ok())
474 .ok_or(IgError::MissingToken("X-SECURITY-TOKEN"))?
475 .to_owned();
476 Ok(SessionTokens {
477 cst,
478 x_security_token,
479 account_id,
480 lightstreamer_endpoint,
481 })
482 }
483
484 async fn mutate_json<B: Serialize + ?Sized, T: DeserializeOwned>(
485 &self,
486 method: Method,
487 path: &str,
488 version: u8,
489 body: &B,
490 ) -> IgResult<T> {
491 self.refresh_oauth_if_needed().await?;
492 let response = self
493 .request(method, path, version, true)?
494 .json(body)
495 .send()
496 .await?;
497 Self::decode_json(response).await
498 }
499
500 fn credentials(&self) -> IgResult<&Credentials> {
501 self.config
502 .credentials
503 .as_ref()
504 .ok_or(IgError::MissingCredentials)
505 }
506
507 fn url(&self, path: &str) -> IgResult<Url> {
508 let mut url = self.base_url.clone();
509 url.set_path(&format!(
510 "{}/{}",
511 self.base_url.path().trim_end_matches('/'),
512 path.trim_start_matches('/')
513 ));
514 url.set_query(None);
515 Ok(url)
516 }
517
518 fn request(
519 &self,
520 method: Method,
521 path: &str,
522 version: u8,
523 include_session: bool,
524 ) -> IgResult<RequestBuilder> {
525 self.request_url(method, self.url(path)?, version, include_session)
526 }
527
528 fn request_url(
529 &self,
530 method: Method,
531 url: Url,
532 version: u8,
533 include_session: bool,
534 ) -> IgResult<RequestBuilder> {
535 let credentials = self.credentials()?;
536 let mut request = self
537 .http
538 .request(method, url)
539 .header("X-IG-API-KEY", credentials.api_key())
540 .header("Accept", "application/json")
541 .header("Content-Type", "application/json")
542 .header("Version", version.to_string());
543 if include_session {
544 let session = self.session.read().map_err(|_| IgError::MissingSession)?;
545 let session = session.as_ref().ok_or(IgError::MissingSession)?;
546 request = match session {
547 Session::V2(tokens) => request
548 .header("CST", &tokens.cst)
549 .header("X-SECURITY-TOKEN", &tokens.x_security_token),
550 Session::V3(oauth) => request
551 .header("Authorization", format!("Bearer {}", oauth.access_token))
552 .header("IG-ACCOUNT-ID", &oauth.account_id),
553 };
554 }
555 Ok(request)
556 }
557
558 async fn get<T: DeserializeOwned>(&self, path: &str, version: u8) -> IgResult<T> {
559 self.refresh_oauth_if_needed().await?;
560 self.send_json(self.request(Method::GET, path, version, true)?)
561 .await
562 }
563
564 async fn send_json<T: DeserializeOwned>(&self, request: RequestBuilder) -> IgResult<T> {
565 Self::decode_json(request.send().await?).await
566 }
567
568 async fn decode_login(
569 &self,
570 response: reqwest::Response,
571 ) -> IgResult<(SessionTokens, LoginResponse)> {
572 let response = Self::ensure_success(response).await?;
573 let cst = response
574 .headers()
575 .get("CST")
576 .and_then(|value| value.to_str().ok())
577 .ok_or(IgError::MissingToken("CST"))?
578 .to_owned();
579 let x_security_token = response
580 .headers()
581 .get("X-SECURITY-TOKEN")
582 .and_then(|value| value.to_str().ok())
583 .ok_or(IgError::MissingToken("X-SECURITY-TOKEN"))?
584 .to_owned();
585 let login: LoginResponse = response.json().await?;
586 let account_id = login.current_account_id.clone().ok_or_else(|| {
587 IgError::InvalidConfiguration(
588 "IG v2 login response did not contain currentAccountId".to_owned(),
589 )
590 })?;
591 let lightstreamer_endpoint = login.lightstreamer_endpoint.clone();
592 Ok((
593 SessionTokens {
594 cst,
595 x_security_token,
596 account_id,
597 lightstreamer_endpoint,
598 },
599 login,
600 ))
601 }
602
603 async fn refresh_oauth_if_needed(&self) -> IgResult<()> {
604 let refresh_token = {
605 let session = self.session.read().map_err(|_| IgError::MissingSession)?;
606 match session.as_ref() {
607 Some(Session::V3(oauth))
608 if oauth.expires_at.saturating_duration_since(Instant::now())
609 <= TOKEN_REFRESH_THRESHOLD =>
610 {
611 Some(oauth.refresh_token.clone())
612 }
613 _ => None,
614 }
615 };
616 let Some(refresh_token) = refresh_token else {
617 return Ok(());
618 };
619
620 #[derive(Serialize)]
621 #[serde(rename_all = "camelCase")]
622 struct RefreshTokenRequest<'a> {
623 refresh_token: &'a str,
624 }
625
626 let response = self
627 .request(Method::POST, "session/refresh-token", 1, false)?
628 .json(&RefreshTokenRequest {
629 refresh_token: &refresh_token,
630 })
631 .send()
632 .await?;
633 let oauth: OAuthToken = Self::decode_json(response).await?;
634 let mut session = self.session.write().map_err(|_| IgError::MissingSession)?;
635 let Some(Session::V3(current)) = session.as_mut() else {
636 return Ok(());
637 };
638 current.access_token = oauth.access_token;
639 current.refresh_token = oauth.refresh_token;
640 current.expires_at = expiry_from(oauth.expires_in);
641 Ok(())
642 }
643
644 async fn decode_json<T: DeserializeOwned>(response: reqwest::Response) -> IgResult<T> {
645 Self::ensure_success(response)
646 .await?
647 .json()
648 .await
649 .map_err(IgError::from)
650 }
651
652 async fn ensure_success(response: reqwest::Response) -> IgResult<reqwest::Response> {
653 if response.status().is_success() {
654 return Ok(response);
655 }
656 let status = response.status();
657 let body = response.text().await.unwrap_or_default();
658 Err(exchange_error(status, &body))
659 }
660}
661
662fn exchange_error(status: StatusCode, body: &str) -> IgError {
663 #[derive(serde::Deserialize)]
664 #[serde(rename_all = "camelCase")]
665 struct ErrorBody {
666 error_code: Option<String>,
667 message: Option<String>,
668 }
669 let parsed = serde_json::from_str::<ErrorBody>(body).ok();
670 let code = parsed
671 .as_ref()
672 .and_then(|value| value.error_code.clone())
673 .unwrap_or_else(|| "unknown".to_owned());
674 let message = parsed
675 .and_then(|value| value.message)
676 .unwrap_or_else(|| body.to_owned());
677 IgError::Exchange {
678 status: status.as_u16(),
679 code,
680 message,
681 }
682}
683
684fn oauth_session(
685 token: OAuthToken,
686 account_id: String,
687 lightstreamer_endpoint: String,
688) -> OAuthSession {
689 OAuthSession {
690 access_token: token.access_token,
691 refresh_token: token.refresh_token,
692 account_id,
693 lightstreamer_endpoint,
694 expires_at: expiry_from(token.expires_in),
695 }
696}
697
698fn expiry_from(expires_in: u64) -> Instant {
699 Instant::now() + Duration::from_secs(expires_in.max(1))
700}
701
702fn encode_path_segment(value: &str) -> String {
703 let mut encoded = String::with_capacity(value.len());
704 for byte in value.bytes() {
705 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
706 encoded.push(char::from(byte));
707 } else {
708 encoded.push_str(&format!("%{byte:02X}"));
709 }
710 }
711 encoded
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use crate::config::Environment;
718
719 #[test]
720 fn custom_base_preserves_gateway_path() {
721 let config = ClientConfig {
722 environment: Environment::Custom {
723 rest_base: "http://127.0.0.1:8080/gateway/deal".to_owned(),
724 },
725 ..ClientConfig::default()
726 };
727 let client = IgClient::new(config).unwrap();
728 assert_eq!(
729 client.url("session").unwrap().as_str(),
730 "http://127.0.0.1:8080/gateway/deal/session"
731 );
732 }
733
734 #[test]
735 fn epics_are_encoded_as_one_path_segment() {
736 assert_eq!(encode_path_segment("CS.D/EUR USD"), "CS.D%2FEUR%20USD");
737 }
738
739 #[test]
740 fn encoded_epic_is_not_encoded_twice_in_a_url() {
741 let config = ClientConfig {
742 environment: Environment::Custom {
743 rest_base: "http://127.0.0.1:8080/gateway/deal".to_owned(),
744 },
745 ..ClientConfig::default()
746 };
747 let client = IgClient::new(config).unwrap();
748 assert_eq!(
749 client.url("markets/CS.D%2FEUR").unwrap().path(),
750 "/gateway/deal/markets/CS.D%2FEUR"
751 );
752 }
753}