Skip to main content

rustrade_data/exchange/lse/
vault.rs

1//! Authenticated REST transport for the London Strategic Edge vault.
2//!
3//! The vault (`api.londonstrategicedge.com/vault`) is the provider's data plane. It is a distinct
4//! host from their catalog/discovery API, with a different symbol key: **the vault keys candles on
5//! the display symbol** (`EUR/USD`, `AAPL`, `ES.F`), not on a dataset slug.
6//!
7//! The HTTP plumbing both of the provider's hosts share — auth header, agent, timeouts, redirect
8//! policy, request rationing and error mapping — lives in an internal transport that this client
9//! wraps; the module here holds what is specific to the vault.
10//!
11//! # Authentication
12//! Every vault endpoint requires an API key, sent as the `x-api-key` header. Supply it explicitly
13//! via [`LseVaultClient::new`] or from `LSE_API_KEY` via [`LseVaultClient::from_env`]. Keys are
14//! free and require no account.
15//!
16//! # ⚠️ Unknown query parameters are ignored, not rejected
17//! The vault answers `200` to a request carrying a misspelled parameter, having silently applied
18//! its default instead. Measured: `resolution=1d` returns **1-minute** bars, byte-identical in
19//! shape to a correct response, and `from`/`since`/`after`/`begin`/`start_date`/`start_time`/`to`/
20//! `until`/`end_date` are all ignored in favour of full history from page one. Only `symbol`,
21//! `timeframe`, `start`, `end` and `limit` are honoured. **Any parameter added here must be
22//! verified against a known-answer query** — a wrong name is never an error.
23
24use crate::exchange::lse::error::LseError;
25use crate::exchange::lse::quota::QuotaStatus;
26use crate::exchange::lse::transport::LseHttpCore;
27use std::{num::NonZeroU32, time::Duration};
28use tokio::sync::SemaphorePermit;
29
30/// Base URL of the vault data plane.
31const VAULT_BASE_URL: &str = "https://api.londonstrategicedge.com/vault";
32
33/// Default rows requested per page of a paged fetch.
34///
35/// Matches the provider's measured
36/// [`max_rows_per_request`](QuotaStatus::max_rows_per_request). The cap is enforced **silently** —
37/// an over-large range returns exactly this many rows with a `200` and no truncation marker — which
38/// is why pagination never treats a short page as the end of the data. A key on a different plan may
39/// be allowed more: read [`usage`](LseVaultClient::usage) and raise this with
40/// [`with_page_limit`](LseVaultClient::with_page_limit) rather than assuming the default is your
41/// key's limit.
42const DEFAULT_PAGE_LIMIT: NonZeroU32 = NonZeroU32::new(5000).unwrap();
43
44/// Authenticated client for the London Strategic Edge vault.
45///
46/// Wraps the internal transport shared with the provider's other host, pointed at the vault, and
47/// adds the vault's own configuration and endpoints: [`usage`](Self::usage) here, paged candles in
48/// [`historical`](super::historical), the catalog in [`reference`](super::reference), bulk export
49/// in [`export`](super::export).
50///
51/// # Request rationing is per client, not per call
52/// Every request this client issues — candle page, export submit, status poll, artifact download —
53/// first passes a gate that bounds requests in flight to
54/// [`with_concurrency`](Self::with_concurrency) and spaces their starts by
55/// [`with_pace`](Self::with_pace). That gate is **shared by clones**, so driving N concurrent
56/// fetches from one client (`Arc` it, or clone it) keeps the aggregate inside those two bounds
57/// rather than multiplying them by N. Two clients built separately by `new` share nothing and
58/// ration independently — one client per API key is the usable unit.
59///
60/// # ⚠️ Licensing
61/// Data retrieved through this client is **not redistributable**. See the
62/// [module documentation](super) and <https://londonstrategicedge.com/terms>.
63#[derive(Clone)]
64pub struct LseVaultClient {
65    core: LseHttpCore,
66    page_limit: NonZeroU32,
67}
68
69impl std::fmt::Debug for LseVaultClient {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        // Deliberately omits the `reqwest::Client`, and therefore its auth header, so the API key
72        // never leaks through `Debug` into logs or error reports. The core's own `Debug` redacts
73        // too; the fields are restated here so this type's output does not depend on that.
74        f.debug_struct("LseVaultClient")
75            .field("base_url", &self.core.base_url())
76            .field("pace", &self.core.pace())
77            .field("page_limit", &self.page_limit)
78            .field("concurrency", &self.core.concurrency())
79            .finish_non_exhaustive()
80    }
81}
82
83impl LseVaultClient {
84    /// Create a client with an explicit API key.
85    ///
86    /// # Redirects are not followed
87    /// The key rides every request as a client-wide `x-api-key` default header, and reqwest's
88    /// cross-host header stripping covers `Authorization`, `Cookie`, `Cookie2`,
89    /// `Proxy-Authorization` and `WWW-Authenticate` — **not** a custom header. Under reqwest's
90    /// default [`Policy::limited(10)`](reqwest::redirect::Policy::limited) a single server-issued
91    /// 302 would therefore re-issue the request, live key attached, against whatever host the
92    /// vault named. The download endpoint is exactly the shape that hands off to object storage,
93    /// and in a CI workflow that key is a repository secret.
94    ///
95    /// [`Policy::none`](reqwest::redirect::Policy::none) makes the "this client only ever requests
96    /// URLs it builds itself" invariant structural rather than a property of the vault's current
97    /// behaviour — a followed redirect *is* a server-supplied URL. An unexpected 3xx surfaces as a
98    /// typed error instead of being followed, and it applies **wholesale**: a same-origin 301/308
99    /// (trailing-slash normalisation by a proxy or CDN) is surfaced too, so a base URL set via
100    /// [`with_base_url`](Self::with_base_url) must serve responses directly.
101    ///
102    /// # Errors
103    /// Returns [`LseError::InvalidCredential`] if the key cannot be encoded as an HTTP header
104    /// value (e.g. non-ASCII bytes), or [`LseError::Http`] if the HTTP client cannot be built.
105    pub fn new(api_key: &str) -> Result<Self, LseError> {
106        Ok(Self {
107            core: LseHttpCore::new(api_key, VAULT_BASE_URL)?,
108            page_limit: DEFAULT_PAGE_LIMIT,
109        })
110    }
111
112    /// Create a client from the `LSE_API_KEY` environment variable.
113    ///
114    /// # Errors
115    /// Returns [`LseError::EnvVar`] if the variable is unset or does not hold valid UTF-8, plus
116    /// anything [`new`](Self::new) returns. Neither message ever contains the variable's value.
117    pub fn from_env() -> Result<Self, LseError> {
118        Ok(Self {
119            core: LseHttpCore::from_env(VAULT_BASE_URL)?,
120            page_limit: DEFAULT_PAGE_LIMIT,
121        })
122    }
123
124    /// Override the vault base URL, for tests against a mock server or a proxy.
125    ///
126    /// Infallible by design: this client only ever requests URLs it builds itself from this base —
127    /// the vault returns bare arrays with no cursor, so there is never a server-supplied URL to
128    /// follow and no trusted origin to derive.
129    #[must_use]
130    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
131        self.core.set_base_url(base_url);
132        self
133    }
134
135    /// Inject a pre-built [`reqwest::Client`].
136    ///
137    /// # ⚠️ The injected client must carry the `x-api-key` header itself
138    /// This replaces the authenticated client built by [`new`](Self::new), auth header included.
139    /// It is intended for transport configuration (proxy, TLS, a shared connection pool) where the
140    /// caller supplies credentials; a client without the header will see every request `401`.
141    ///
142    /// # ⚠️ It must also carry its own timeouts
143    /// [`new`](Self::new) configures a 30s total timeout and a 30s read timeout; `reqwest`'s
144    /// default is **neither**. Replacing the client drops both, and a timeout-less client
145    /// interacts badly with the [request gate](Self#request-rationing-is-per-client-not-per-call):
146    /// a permit is held for the whole request, so with the default concurrency of 2, two stalled
147    /// connections exhaust the gate *permanently* — for this client and every clone of it, with no
148    /// error, no log, and nothing to observe but a run that stops making progress. Set
149    /// [`timeout`](reqwest::ClientBuilder::timeout) and
150    /// [`read_timeout`](reqwest::ClientBuilder::read_timeout) on any client passed here.
151    ///
152    /// # ⚠️ And its own redirect policy
153    /// [`new`](Self::new) sets
154    /// [`Policy::none`](reqwest::redirect::Policy::none) so the API key cannot ride a server-issued
155    /// redirect to another host; reqwest's default is `Policy::limited(10)`, which strips only the
156    /// standard auth headers and would forward a custom `x-api-key` intact. A client supplied here
157    /// that carries the key **must** set `Policy::none` itself, or a single 302 from the vault (or
158    /// from a proxy in front of it) leaks the credential with no error and no log.
159    #[must_use]
160    pub fn with_client(mut self, client: reqwest::Client) -> Self {
161        self.core.set_client(client);
162        self
163    }
164
165    /// Override the minimum delay between the starts of two requests.
166    ///
167    /// Applies to **every** request this client issues, not just successive pages of one fetch, and
168    /// is enforced against the [gate](Self#request-rationing-is-per-client-not-per-call) this client
169    /// shares with its clones — so N concurrent fetches still start one request per `pace` between
170    /// them.
171    ///
172    /// Pass [`Duration::ZERO`] to disable pacing entirely, at which point staying within the
173    /// provider's [`calls_per_minute`](QuotaStatus::calls_per_minute) allowance becomes the
174    /// caller's responsibility. The default is derived from that documented allowance.
175    #[must_use]
176    pub fn with_pace(mut self, pace: Duration) -> Self {
177        self.core.set_pace(pace);
178        self
179    }
180
181    /// Override how many rows a paged fetch requests per page.
182    ///
183    /// The default is the provider's measured
184    /// [`max_rows_per_request`](QuotaStatus::max_rows_per_request); raise it only against a key whose
185    /// [`usage`](Self::usage) reports a higher one. Requesting **more** than your key allows is not
186    /// an error — the vault caps the page silently, and pagination handles a short page — so an
187    /// over-large value costs nothing but degrades into more, smaller pages. The option print walk,
188    /// which cannot page past a short page, bounds this by the cap [`usage`](Self::usage) reports. Requesting fewer than
189    /// the cap is a legitimate way to bound per-page memory or response latency.
190    ///
191    /// # Why `NonZeroU32`, where [`with_concurrency`](Self::with_concurrency) clamps
192    /// A concurrency of `0` parks every request — visibly broken, so clamping to `1` is a safe
193    /// reading of a caller slip. A page limit of `0` is worse than broken: the vault answers `200`
194    /// with an empty page, which pagination reads as the end of the data, so the fetch **completes
195    /// successfully having returned nothing**. That is a silent wrong answer, and the type system
196    /// rules it out rather than a clamp papering over it.
197    #[must_use]
198    pub fn with_page_limit(mut self, limit: NonZeroU32) -> Self {
199        self.page_limit = limit;
200        self
201    }
202
203    /// Override how many requests this client may have in flight at once.
204    ///
205    /// The default is the provider's measured [`vault_concurrency`](QuotaStatus::vault_concurrency);
206    /// raise it only against a key whose [`usage`](Self::usage) reports a higher one. `0` is
207    /// clamped to `1`, since a client that can never issue a request is never what was meant.
208    ///
209    /// # ⚠️ Call this before cloning
210    /// This installs a **fresh** gate. Clones taken *before* the call keep the old one and ration
211    /// separately from this client, which defeats the point of sharing; clones taken after share
212    /// the new gate as usual.
213    #[must_use]
214    pub fn with_concurrency(mut self, concurrency: usize) -> Self {
215        self.core.set_concurrency(concurrency);
216        self
217    }
218
219    /// Wait for this client's shared gate to admit one request.
220    ///
221    /// The returned permit bounds concurrency and must be held until the response body has been
222    /// consumed — dropping it early lets another request start while this one is still on the wire.
223    /// Endpoint families outside this module call it before issuing a request that does not go
224    /// through [`get_json`](Self::get_json).
225    pub(crate) async fn enter_gate(&self) -> Option<SemaphorePermit<'_>> {
226        self.core.enter_gate().await
227    }
228
229    /// The configured vault base URL.
230    ///
231    /// Endpoint families build their own URLs from this, preserving the invariant that this client
232    /// never follows a server-supplied URL.
233    pub(crate) fn base_url(&self) -> &str {
234        self.core.base_url()
235    }
236
237    /// The configured rows-per-page for a paged fetch.
238    ///
239    /// Endpoint families send this as the `limit` query parameter. The non-zero guarantee
240    /// [`with_page_limit`](Self::with_page_limit) argues for is carried in the type rather than
241    /// asserted here, so a caller cannot reintroduce the empty-page-reads-as-end-of-data case.
242    pub(crate) fn page_limit(&self) -> NonZeroU32 {
243        self.page_limit
244    }
245
246    /// The configured, authenticated HTTP client.
247    ///
248    /// For endpoint families that need a verb or a response handling this module's
249    /// [`get_json`](Self::get_json) does not cover — a `POST` that answers `202`, or a streamed
250    /// download with `Range` resume.
251    pub(crate) fn http(&self) -> &reqwest::Client {
252        self.core.http()
253    }
254
255    /// Fetch the current allowance position.
256    ///
257    /// The allowance is **shared between streaming and bulk export**, so a consumer running both
258    /// must budget against this single pool. See [`QuotaStatus`] for what is and is not reported —
259    /// notably, no window reset time is available.
260    ///
261    /// # Errors
262    /// See [`LseError`].
263    pub async fn usage(&self) -> Result<QuotaStatus, LseError> {
264        self.get_json("usage", &[]).await
265    }
266
267    /// Issue an authenticated `GET` against a vault path and deserialise the JSON body.
268    ///
269    /// Rationed by this client's shared [gate](Self#request-rationing-is-per-client-not-per-call),
270    /// so a caller driving several of these concurrently does not have to pace them itself.
271    ///
272    /// # Errors
273    /// Maps a `429` to [`LseError::RateLimited`] (carrying `Retry-After` when present), any other
274    /// non-success status to [`LseError::Api`] with the provider's diagnostic unwrapped from its
275    /// envelope, and a body that will not decode to [`LseError::Deserialize`].
276    pub(crate) async fn get_json<T>(
277        &self,
278        path: &str,
279        query: &[(&str, String)],
280    ) -> Result<T, LseError>
281    where
282        T: serde::de::DeserializeOwned,
283    {
284        self.core.get_json(path, query).await
285    }
286}
287
288#[cfg(test)]
289#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
290mod tests {
291    use super::*;
292    use crate::exchange::lse::transport::API_KEY_ENV;
293    use tokio::time::Instant;
294
295    #[test]
296    fn debug_does_not_leak_the_api_key() {
297        let client = LseVaultClient::new("super-secret-key").unwrap();
298
299        assert!(!format!("{client:?}").contains("super-secret-key"));
300    }
301
302    #[test]
303    fn debug_reports_the_vault_configuration() {
304        // The four fields callers actually diagnose with: where it points, how it is rationed, and
305        // how large a page it asks for.
306        let client = LseVaultClient::new("key").unwrap();
307        let rendered = format!("{client:?}");
308
309        assert!(rendered.contains(VAULT_BASE_URL), "{rendered}");
310        assert!(rendered.contains("page_limit: 5000"), "{rendered}");
311        assert!(rendered.contains("concurrency: 2"), "{rendered}");
312    }
313
314    #[test]
315    fn a_key_carrying_a_control_character_is_a_typed_error_not_a_panic() {
316        // The realistic case is a key copied with a trailing newline. It is rejected rather than
317        // trimmed: silently mutating a credential would hide the malformed value from the user,
318        // and the typed error names the problem.
319        // Note a horizontal tab is *not* here: HTTP header values permit it, so it reaches the
320        // provider and comes back as a plain `401` instead.
321        for key in [
322            "key-with-\nnewline",
323            "key-with-\rcarriage",
324            "key\u{0}with-nul",
325        ] {
326            assert!(
327                matches!(
328                    LseVaultClient::new(key).unwrap_err(),
329                    LseError::InvalidCredential(_)
330                ),
331                "{key:?} should be rejected"
332            );
333        }
334    }
335
336    #[test]
337    fn from_env_reports_the_variable_name_when_unset() {
338        temp_env::with_var_unset(API_KEY_ENV, || {
339            let error = LseVaultClient::from_env().unwrap_err();
340
341            assert!(matches!(error, LseError::EnvVar(_)));
342            assert!(error.to_string().contains(API_KEY_ENV));
343        });
344    }
345
346    #[test]
347    fn from_env_builds_a_client_when_set() {
348        temp_env::with_var(API_KEY_ENV, Some("test-key"), || {
349            assert!(LseVaultClient::from_env().is_ok());
350        });
351    }
352
353    #[test]
354    fn from_env_points_at_the_vault_not_at_whatever_the_core_defaults_to() {
355        // The core is host-agnostic, so the vault base URL is this type's contribution and a
356        // regression in it would silently send every vault call to the wrong host.
357        temp_env::with_var(API_KEY_ENV, Some("test-key"), || {
358            let client = LseVaultClient::from_env().unwrap();
359
360            assert_eq!(client.base_url(), VAULT_BASE_URL);
361        });
362    }
363
364    #[test]
365    fn new_points_at_the_vault() {
366        assert_eq!(
367            LseVaultClient::new("key").unwrap().base_url(),
368            VAULT_BASE_URL
369        );
370    }
371
372    #[test]
373    fn with_base_url_overrides_the_host() {
374        let client = LseVaultClient::new("key")
375            .unwrap()
376            .with_base_url("http://127.0.0.1:1/mock");
377
378        assert_eq!(client.base_url(), "http://127.0.0.1:1/mock");
379    }
380
381    #[test]
382    fn with_page_limit_is_carried_independently_of_the_shared_core() {
383        let limit = NonZeroU32::new(17).unwrap();
384        let client = LseVaultClient::new("key").unwrap().with_page_limit(limit);
385
386        assert_eq!(client.page_limit(), limit);
387        assert!(format!("{client:?}").contains("page_limit: 17"));
388    }
389
390    /// Clones must queue together — otherwise handing a clone to each of N concurrent fetches would
391    /// restore exactly the N × rate the gate exists to prevent.
392    #[tokio::test(start_paused = true)]
393    async fn a_cloned_client_queues_at_the_same_gate_as_its_original() {
394        let pace = Duration::from_millis(300);
395        let client = LseVaultClient::new("key").unwrap().with_pace(pace);
396        let clone = client.clone();
397        let started = Instant::now();
398
399        let _first = client.enter_gate().await;
400        let _second = clone.enter_gate().await;
401
402        assert_eq!(started.elapsed(), pace);
403    }
404
405    #[test]
406    fn a_concurrency_of_zero_is_clamped_rather_than_parking_every_request_forever() {
407        let client = LseVaultClient::new("key").unwrap().with_concurrency(0);
408
409        assert!(format!("{client:?}").contains("concurrency: 1"));
410    }
411}