projectx_client/
config.rs1use url::{Host, Url};
7
8use crate::Error;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct Endpoints {
13 api_base: String,
14 realtime_base: String,
15}
16
17impl Endpoints {
18 #[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 #[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 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 #[must_use]
81 pub fn api_base(&self) -> &str {
82 &self.api_base
83 }
84
85 #[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}