bezant/client.rs
1//! Client wrapper: composes the auto-generated [`bezant_api::IbRestApiClient`]
2//! with saner defaults for talking to the local IBKR Client Portal Gateway.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use tracing::warn;
8
9use crate::error::{Error, Result};
10use crate::jar::NameKeyedJar;
11
12/// Default base URL of the Client Portal Gateway when run locally via the
13/// bundled Docker image.
14pub const DEFAULT_BASE_URL: &str = "https://localhost:5000/v1/api";
15
16/// A configured client for the IBKR Client Portal Web API.
17///
18/// `Client` holds a [`bezant_api::IbRestApiClient`] internally. The `Arc`
19/// makes it cheap to clone — share one instance across your app.
20#[derive(Clone)]
21pub struct Client {
22 inner: Arc<ClientInner>,
23}
24
25impl std::fmt::Debug for Client {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 // The inner reqwest client doesn't implement Debug cleanly;
28 // project the surface that's useful at trace sites.
29 f.debug_struct("Client")
30 .field("base_url", &self.inner.base_url.as_str())
31 .field("gateway_root", &self.inner.gateway_root.as_str())
32 .finish_non_exhaustive()
33 }
34}
35
36struct ClientInner {
37 api: bezant_api::IbRestApiClient,
38 http: reqwest::Client,
39 base_url: url::Url,
40 gateway_root: url::Url,
41 cookie_jar: Arc<NameKeyedJar>,
42}
43
44impl Client {
45 /// Construct a client pointed at `base_url` with Bezant's recommended
46 /// defaults (accepts the Gateway's self-signed cert, 30s timeout,
47 /// persistent cookie jar).
48 ///
49 /// # Errors
50 /// Returns [`Error::InvalidBaseUrl`] if `base_url` is not a valid URL and
51 /// [`Error::Http`] if reqwest fails to build its client.
52 pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
53 ClientBuilder::new(base_url).build()
54 }
55
56 /// Return a builder for fine-grained configuration.
57 pub fn builder(base_url: impl AsRef<str>) -> ClientBuilder {
58 ClientBuilder::new(base_url)
59 }
60
61 /// Borrow the underlying generated API client for raw endpoint access.
62 ///
63 /// Every one of the ~155 CPAPI endpoints is available via typed methods
64 /// on `bezant_api::IbRestApiClient`.
65 #[must_use]
66 pub fn api(&self) -> &bezant_api::IbRestApiClient {
67 &self.inner.api
68 }
69
70 /// Borrow the underlying `reqwest::Client` for untyped HTTP passthrough
71 /// (e.g. when you want to proxy CPAPI calls rather than decode them).
72 #[must_use]
73 pub fn http(&self) -> &reqwest::Client {
74 &self.inner.http
75 }
76
77 /// Base URL the client is pointed at, including the CPAPI prefix
78 /// (e.g. `https://localhost:5000/v1/api`).
79 #[must_use]
80 pub fn base_url(&self) -> &url::Url {
81 &self.inner.base_url
82 }
83
84 /// The Gateway's root URL — [`Client::base_url`] with the CPAPI prefix
85 /// trimmed off (e.g. `https://localhost:5000/`). Useful when you need to
86 /// hit paths the Gateway serves outside `/v1/api` (login, static assets).
87 #[must_use]
88 pub fn gateway_root_url(&self) -> &url::Url {
89 &self.inner.gateway_root
90 }
91
92 /// Shared cookie jar backing the underlying `reqwest::Client`.
93 ///
94 /// Expose this when you're running bezant alongside a reverse proxy
95 /// (for example bezant-server's `/sso/Login` passthrough): you can
96 /// inject cookies that arrive from the proxied caller so that typed
97 /// API calls made through the same `Client` see the same session.
98 ///
99 /// The underlying [`NameKeyedJar`] keys cookies purely by name —
100 /// inserting `JSESSIONID=NEW` overwrites `JSESSIONID=OLD`
101 /// regardless of the path either was originally set on. This trades
102 /// RFC 6265 path/domain semantics for "the Gateway never sees two
103 /// values for the same cookie name", which CPGateway requires.
104 #[must_use]
105 pub fn cookie_jar(&self) -> Arc<NameKeyedJar> {
106 Arc::clone(&self.inner.cookie_jar)
107 }
108}
109
110/// Builder for [`Client`].
111#[must_use]
112#[derive(Debug, Clone)]
113pub struct ClientBuilder {
114 base_url: String,
115 accept_invalid_certs: bool,
116 timeout: Duration,
117 user_agent: String,
118 follow_redirects: bool,
119 http1_only: bool,
120}
121
122impl Default for ClientBuilder {
123 /// Create a builder pointed at [`DEFAULT_BASE_URL`] — the
124 /// local Docker setup. Override via [`ClientBuilder::new`] when
125 /// you target a non-default Gateway address.
126 fn default() -> Self {
127 Self::new(DEFAULT_BASE_URL)
128 }
129}
130
131impl ClientBuilder {
132 /// Start a new builder pointed at `base_url`.
133 pub fn new(base_url: impl AsRef<str>) -> Self {
134 Self {
135 base_url: base_url.as_ref().to_owned(),
136 accept_invalid_certs: true,
137 timeout: Duration::from_secs(30),
138 user_agent: format!("bezant/{}", env!("CARGO_PKG_VERSION")),
139 follow_redirects: true,
140 // Default to HTTP/1.1-only because the production CPAPI path is
141 // fronted by an Akamai CDN that rejects empty POSTs without a
142 // Content-Length header — something hyper can end up emitting
143 // under HTTP/2. See `ClientBuilder::http1_only` for the escape
144 // hatch.
145 http1_only: true,
146 }
147 }
148
149 /// Accept self-signed / invalid TLS certificates. Defaults to **true**
150 /// because the Gateway ships with a self-signed cert; set to `false`
151 /// once you install a trusted cert.
152 pub fn accept_invalid_certs(mut self, accept: bool) -> Self {
153 self.accept_invalid_certs = accept;
154 self
155 }
156
157 /// Request timeout for every HTTP call (defaults to 30s).
158 pub fn timeout(mut self, timeout: Duration) -> Self {
159 self.timeout = timeout;
160 self
161 }
162
163 /// Override the `User-Agent` header (defaults to `bezant/<version>`).
164 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
165 self.user_agent = ua.into();
166 self
167 }
168
169 /// Follow HTTP redirects automatically. Defaults to `true` (reqwest's
170 /// normal 10-hop policy). Set to `false` when you're operating as a
171 /// reverse proxy and want 3xx responses passed through to the caller —
172 /// otherwise the browser ends up seeing the redirected body at the
173 /// original URL, which breaks relative asset paths on pages like
174 /// CPGateway's `/sso/Login`.
175 pub fn follow_redirects(mut self, follow: bool) -> Self {
176 self.follow_redirects = follow;
177 self
178 }
179
180 /// Force HTTP/1.1 only (no ALPN upgrade to HTTP/2). Defaults to `true`
181 /// because IBKR fronts the CPAPI with an Akamai CDN that rejects
182 /// empty-body POSTs shipped over HTTP/2 with `411 Length Required`.
183 /// Flip this to `false` if you're targeting a Gateway deployment that
184 /// does not sit behind Akamai (e.g. a self-hosted instance) and you
185 /// want the latency benefits of HTTP/2.
186 pub fn http1_only(mut self, http1_only: bool) -> Self {
187 self.http1_only = http1_only;
188 self
189 }
190
191 /// Finish configuration and build the [`Client`].
192 ///
193 /// # Errors
194 /// Propagates URL parse errors and reqwest build errors.
195 pub fn build(self) -> Result<Client> {
196 let redirect_policy = if self.follow_redirects {
197 reqwest::redirect::Policy::default()
198 } else {
199 reqwest::redirect::Policy::none()
200 };
201 let cookie_jar = Arc::new(NameKeyedJar::new());
202 let mut http_builder = reqwest::Client::builder()
203 .cookie_provider(Arc::clone(&cookie_jar))
204 .danger_accept_invalid_certs(self.accept_invalid_certs)
205 .timeout(self.timeout)
206 // Distinct connect timeout below the holistic `timeout` so a
207 // dead Gateway surfaces fast (5s) instead of after the full
208 // 30s — important for liveness probes / fast retry loops.
209 .connect_timeout(Duration::from_secs(5))
210 // Cap idle pool: reqwest's default is `usize::MAX` which
211 // can leak connections forever under bursty traffic. 32
212 // is plenty for a single Gateway (which talks to one
213 // upstream host at most).
214 .pool_max_idle_per_host(32)
215 // Reap idle connections after 90s so we don't pin file
216 // descriptors against a Gateway that's already dropped
217 // its end of the socket.
218 .pool_idle_timeout(Duration::from_secs(90))
219 // TCP keepalive on the connection itself catches NAT
220 // table evictions / silent drops mid-idle.
221 .tcp_keepalive(Duration::from_secs(30))
222 .user_agent(&self.user_agent)
223 .redirect(redirect_policy);
224 if self.http1_only {
225 http_builder = http_builder.http1_only();
226 }
227 let http = http_builder.build().map_err(Error::Http)?;
228
229 if self.accept_invalid_certs {
230 warn!(
231 "bezant: accepting invalid TLS certs (Gateway default self-signed cert). \
232 Set ClientBuilder::accept_invalid_certs(false) once you install a trusted cert."
233 );
234 }
235
236 let api = bezant_api::IbRestApiClient::with_client(&self.base_url, http.clone())
237 .map_err(|e| Error::InvalidBaseUrl(e.to_string()))?;
238 let base_url: url::Url = self
239 .base_url
240 .parse()
241 .map_err(|e: url::ParseError| Error::InvalidBaseUrl(e.to_string()))?;
242 let gateway_root = derive_gateway_root(&base_url);
243
244 Ok(Client {
245 inner: Arc::new(ClientInner {
246 api,
247 http,
248 base_url,
249 gateway_root,
250 cookie_jar,
251 }),
252 })
253 }
254}
255
256/// Strip the CPAPI prefix off `base_url` to recover the Gateway root.
257///
258/// Handles both the `.../v1/api` and `.../v1/api/` forms; returns the
259/// origin (`scheme://host[:port]/`) if we can't identify the prefix.
260fn derive_gateway_root(base_url: &url::Url) -> url::Url {
261 let mut root = base_url.clone();
262 // Normalise away trailing slashes so path segment editing is consistent.
263 if root.path().ends_with('/') {
264 let trimmed = root.path().trim_end_matches('/').to_owned();
265 root.set_path(&trimmed);
266 }
267 if root.path().ends_with("/v1/api") {
268 let new_path = root.path().strip_suffix("/v1/api").unwrap_or("").to_owned();
269 root.set_path(&new_path);
270 }
271 // Always end the root with a single '/', so callers can `.join("sso/Login")`.
272 if !root.path().ends_with('/') {
273 let with_slash = format!("{}/", root.path());
274 root.set_path(&with_slash);
275 }
276 root.set_query(None);
277 root.set_fragment(None);
278 root
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn gateway_root_strips_v1_api() {
287 let base: url::Url = "https://localhost:5000/v1/api".parse().unwrap();
288 assert_eq!(
289 derive_gateway_root(&base).as_str(),
290 "https://localhost:5000/"
291 );
292 }
293
294 #[test]
295 fn gateway_root_strips_trailing_slash() {
296 let base: url::Url = "https://localhost:5000/v1/api/".parse().unwrap();
297 assert_eq!(
298 derive_gateway_root(&base).as_str(),
299 "https://localhost:5000/"
300 );
301 }
302
303 #[test]
304 fn gateway_root_preserves_custom_prefix() {
305 // Some self-hosted deployments prefix the CPAPI path with their own
306 // routing — if there's no `/v1/api` suffix we just drop trailing
307 // slashes and keep whatever's there.
308 let base: url::Url = "https://gw.example.com/ibkr/v1/api".parse().unwrap();
309 assert_eq!(
310 derive_gateway_root(&base).as_str(),
311 "https://gw.example.com/ibkr/"
312 );
313 }
314}