Skip to main content

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}