Skip to main content

truefix_ig_client/
client.rs

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/// IG REST trading client.
46///
47/// Session credentials are kept behind a lock so that one client can be shared among
48/// concurrent read-only tasks after [`Self::login`] succeeds. Calls which change server state are
49/// deliberately made once and are never automatically replayed.
50#[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    /// Constructs a client without contacting IG.
60    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    /// Authenticates using the protocol selected in [`ClientConfig::authentication`].
79    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    /// Authenticates with v2 and stores the CST/X-SECURITY-TOKEN pair.
87    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    /// Authenticates with v3 OAuth and stores refreshable bearer credentials.
109    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    /// Deletes the current session and clears local tokens only after IG accepts the request.
162    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    /// Returns the accounts available to the authenticated client.
174    pub async fn accounts(&self) -> IgResult<AccountsResponse> {
175        self.get("accounts", 1).await
176    }
177
178    /// Returns authoritative details for the currently active session/account.
179    pub async fn session_details(&self) -> IgResult<LoginResponse> {
180        self.get("session", 1).await
181    }
182
183    /// Switches the active V2 account without changing the user's default.
184    ///
185    /// IG rotates the X-SECURITY-TOKEN when the active account changes. The
186    /// returned header is committed atomically with the local account ID so
187    /// subsequent REST and Lightstreamer calls cannot retain the old account
188    /// token.
189    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    /// Returns all open positions for the active account.
231    pub async fn positions(&self) -> IgResult<PositionsResponse> {
232        self.get("positions", 2).await
233    }
234
235    /// Returns market metadata and a latest price snapshot for one IG epic.
236    pub async fn market(&self, epic: &str) -> IgResult<MarketDetails> {
237        self.get(&format!("markets/{}", encode_path_segment(epic)), 3)
238            .await
239    }
240
241    /// Searches the market catalogue using IG's `/markets` endpoint.
242    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    /// Returns every instrument category visible to the authenticated account.
251    pub async fn instrument_categories(&self) -> IgResult<InstrumentCategoriesResponse> {
252        self.get("categories", 1).await
253    }
254
255    /// Returns one zero-based page of instruments in an account-visible category.
256    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    /// Returns up to 500 detailed account activities for one explicit UTC window.
281    ///
282    /// The response retains IG's next-page link so callers can explicitly decide
283    /// whether older execution evidence is relevant to their reconciliation window.
284    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    /// Follows one IG-provided activity paging link without allowing the
302    /// response to redirect credentials to another endpoint or host.
303    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    /// Returns historical prices for an epic at the requested IG resolution.
323    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    /// Creates a position. This operation is never automatically retried.
348    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    /// Resolves a submission acknowledgement into IG's authoritative deal
362    /// confirmation. Callers must not treat the initial deal reference as an
363    /// accepted order.
364    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    /// Returns every working order for the active account.
373    pub async fn working_orders(&self) -> IgResult<WorkingOrdersResponse> {
374        self.get("workingorders", 2).await
375    }
376
377    /// Creates a working order. Resolve the returned reference with
378    /// [`Self::deal_confirmation`] before treating it as accepted.
379    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    /// Updates an existing working order. Writes are never automatically replayed.
388    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    /// Deletes an existing working order. Writes are never automatically replayed.
403    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    /// Opens IG's Lightstreamer endpoint using credentials issued by v2 login.
418    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}