Skip to main content

projectx_client/
config.rs

1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Provider endpoint configuration.
5
6use url::{Host, Url};
7
8use crate::Error;
9
10/// REST and real-time endpoints for one `ProjectX` deployment.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct Endpoints {
13    api_base: String,
14    realtime_base: String,
15}
16
17impl Endpoints {
18    /// Returns the hosted `TopstepX` endpoints.
19    #[must_use]
20    pub fn topstepx() -> Self {
21        Self {
22            api_base: "https://api.topstepx.com/".to_owned(),
23            realtime_base: "https://rtc.topstepx.com/".to_owned(),
24        }
25    }
26
27    /// Returns the hosted `TheFuturesDesk` endpoints.
28    #[must_use]
29    pub fn thefuturesdesk() -> Self {
30        Self {
31            api_base: "https://api.thefuturesdesk.projectx.com/".to_owned(),
32            realtime_base: "https://rtc.thefuturesdesk.projectx.com/".to_owned(),
33        }
34    }
35
36    /// Creates a custom `ProjectX` endpoint pair.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error for invalid URLs, URLs that cannot serve as a base, or
41    /// non-HTTPS remote endpoints. Plain HTTP is accepted only for exact
42    /// loopback hosts so deterministic local fixtures do not weaken production
43    /// credential transport.
44    pub fn custom(api_base: &str, realtime_base: &str) -> Result<Self, Error> {
45        Ok(Self {
46            api_base: parse_base(api_base)?,
47            realtime_base: parse_base(realtime_base)?,
48        })
49    }
50
51    pub(crate) fn api_url(&self, path: &str) -> Result<Url, Error> {
52        Url::parse(&self.api_base)
53            .map_err(Error::Url)?
54            .join(path.trim_start_matches('/'))
55            .map_err(Error::Url)
56    }
57
58    pub(crate) fn hub_url(&self, hub_path: &str) -> Result<Url, Error> {
59        let mut url = Url::parse(&self.realtime_base).map_err(Error::Url)?;
60        let scheme = match url.scheme() {
61            "https" => "wss",
62            "http" => "ws",
63            _ => {
64                return Err(Error::Configuration(
65                    "real-time endpoint must use HTTP or HTTPS".to_owned(),
66                ));
67            }
68        };
69        url.set_scheme(scheme).map_err(|()| {
70            Error::Configuration("real-time endpoint scheme could not be changed".to_owned())
71        })?;
72        url.join(&format!("hubs/{hub_path}")).map_err(Error::Url)
73    }
74
75    pub(crate) fn uses_plaintext_transport(&self) -> bool {
76        self.api_base.starts_with("http://") || self.realtime_base.starts_with("http://")
77    }
78
79    /// Returns the configured REST base URL.
80    #[must_use]
81    pub fn api_base(&self) -> &str {
82        &self.api_base
83    }
84
85    /// Returns the configured real-time base URL.
86    #[must_use]
87    pub fn realtime_base(&self) -> &str {
88        &self.realtime_base
89    }
90}
91
92impl Default for Endpoints {
93    fn default() -> Self {
94        Self::topstepx()
95    }
96}
97
98fn parse_base(raw: &str) -> Result<String, Error> {
99    let mut url = Url::parse(raw).map_err(Error::Url)?;
100    if url.cannot_be_a_base()
101        || !matches!(url.scheme(), "http" | "https")
102        || url.host_str().is_none()
103    {
104        return Err(Error::Configuration(
105            "endpoint must be an absolute HTTP(S) base URL with a host".to_owned(),
106        ));
107    }
108    if url.scheme() == "http" && !has_loopback_host(&url) {
109        return Err(Error::Configuration(
110            "remote endpoints must use HTTPS; plain HTTP is allowed only for loopback fixtures"
111                .to_owned(),
112        ));
113    }
114    if !url.username().is_empty()
115        || url.password().is_some()
116        || url.query().is_some()
117        || url.fragment().is_some()
118    {
119        return Err(Error::Configuration(
120            "endpoint base URL must not contain credentials, a query, or a fragment".to_owned(),
121        ));
122    }
123    if !url.path().ends_with('/') {
124        let new_path = format!("{}/", url.path());
125        url.set_path(&new_path);
126    }
127    Ok(url.into())
128}
129
130fn has_loopback_host(url: &Url) -> bool {
131    match url.host() {
132        Some(Host::Ipv4(address)) => address.is_loopback(),
133        Some(Host::Ipv6(address)) => address.is_loopback(),
134        Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
135        None => false,
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn hosted_endpoint_presets_match_the_documented_provider_urls() {
145        let topstepx = Endpoints::topstepx();
146        assert_eq!(topstepx.api_base(), "https://api.topstepx.com/");
147        assert_eq!(topstepx.realtime_base(), "https://rtc.topstepx.com/");
148        assert_eq!(
149            topstepx
150                .hub_url("user")
151                .unwrap_or_else(|error| panic!("fixture hub URL must be valid: {error}"))
152                .as_str(),
153            "wss://rtc.topstepx.com/hubs/user"
154        );
155        assert_eq!(
156            topstepx
157                .api_url("api/Auth/loginKey")
158                .unwrap_or_else(|error| panic!("fixture API URL must be valid: {error}"))
159                .as_str(),
160            "https://api.topstepx.com/api/Auth/loginKey"
161        );
162
163        let thefuturesdesk = Endpoints::thefuturesdesk();
164        assert_eq!(
165            thefuturesdesk.api_base(),
166            "https://api.thefuturesdesk.projectx.com/"
167        );
168        assert_eq!(
169            thefuturesdesk.realtime_base(),
170            "https://rtc.thefuturesdesk.projectx.com/"
171        );
172        assert_eq!(
173            thefuturesdesk
174                .hub_url("market")
175                .unwrap_or_else(|error| panic!("fixture hub URL must be valid: {error}"))
176                .as_str(),
177            "wss://rtc.thefuturesdesk.projectx.com/hubs/market"
178        );
179        assert_eq!(
180            thefuturesdesk
181                .api_url("api/Auth/loginKey")
182                .unwrap_or_else(|error| panic!("fixture API URL must be valid: {error}"))
183                .as_str(),
184            "https://api.thefuturesdesk.projectx.com/api/Auth/loginKey"
185        );
186
187        assert_eq!(Endpoints::default(), topstepx);
188        assert_ne!(thefuturesdesk, topstepx);
189    }
190
191    #[test]
192    fn custom_endpoints_normalize_trailing_slashes() {
193        let endpoints = Endpoints::custom(
194            "https://example.test/gateway",
195            "https://realtime.example.test/service",
196        )
197        .unwrap_or_else(|error| panic!("fixture endpoints must be valid: {error}"));
198
199        assert_eq!(endpoints.api_base(), "https://example.test/gateway/");
200        assert_eq!(
201            endpoints.realtime_base(),
202            "https://realtime.example.test/service/"
203        );
204    }
205
206    #[test]
207    fn custom_endpoints_reject_ambiguous_or_secret_bases() {
208        for invalid in [
209            "https://user:password@example.test/",
210            "https://example.test/?tenant=secret",
211            "https://example.test/#fragment",
212            "file:///tmp/projectx",
213            "https://",
214            "http://gateway.example.test/",
215        ] {
216            assert!(Endpoints::custom(invalid, "https://example.test/").is_err());
217            assert!(Endpoints::custom("https://example.test/", invalid).is_err());
218        }
219    }
220
221    #[test]
222    fn custom_endpoints_allow_plain_http_only_on_exact_loopback_hosts() {
223        for loopback in [
224            "http://127.0.0.1:8080",
225            "http://[::1]:8080",
226            "http://localhost:8080",
227        ] {
228            Endpoints::custom(loopback, loopback)
229                .unwrap_or_else(|error| panic!("loopback fixture must be accepted: {error}"));
230        }
231
232        for remote in [
233            "http://127.0.0.1.example.test/",
234            "http://localhost.example.test/",
235            "http://192.168.1.10/",
236        ] {
237            assert!(Endpoints::custom(remote, "https://example.test/").is_err());
238            assert!(Endpoints::custom("https://example.test/", remote).is_err());
239        }
240    }
241}