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}