Skip to main content

rustrade_data/exchange/lse/
data_api.rs

1//! Authenticated REST transport for the London Strategic Edge data API.
2//!
3//! The data API (`data-api.londonstrategicedge.com`) is the provider's **second** host, distinct
4//! from the [vault](super::vault) in path, payload shape and symbol key. It serves the reference
5//! families the vault has no candle path for: `GET /bond-yields` and `/bond-yields/stats` are
6//! wrapped in [`bond_yield`](super::bond_yield).
7//!
8//! The HTTP plumbing both hosts share — auth header, agent, timeouts, redirect policy, request
9//! rationing and error mapping — lives in an internal transport that this client wraps, exactly as
10//! [`LseVaultClient`](super::vault::LseVaultClient) does; the module here holds what is specific to
11//! the data API.
12//!
13//! # Authentication
14//! One API key authenticates **both** hosts, read from the same `LSE_API_KEY` variable and sent as
15//! the same `x-api-key` header. Supply it explicitly via [`LseDataApiClient::new`] or from the
16//! environment via [`LseDataApiClient::from_env`]. Keys are free and require no account.
17//!
18//! # ⚠️ The two hosts disagree, and neither errors on the other's spelling
19//! They are not interchangeable views of one dataset:
20//!
21//! - **Country codes differ.** The vault catalog spells the United Kingdom `GB`; this host spells
22//!   it `UK`, and `GB` here returns `200` with zero rows rather than an error. See
23//!   [`LseBondYieldStats::normalise_country`](super::bond_yield::LseBondYieldStats::normalise_country).
24//! - **Dates differ.** The vault stamps to the microsecond
25//!   (`2024-01-02 09:09:00.000000`); the bond-yield rows here carry a bare `YYYY-MM-DD`.
26//! - **Volume differs.** The vault omits `volume` for FX entirely, while this host reports one for
27//!   the same bar.
28//!
29//! Anything crossing between them must normalise deliberately. Nothing here does it implicitly.
30//!
31//! # ⚠️ Rationing is per client, not per key
32//! This client and an [`LseVaultClient`](super::vault::LseVaultClient) built separately hold
33//! **independent** gates, so a consumer driving both issues up to the sum of their two rates. That
34//! is the right default for two hosts whose limits are enforced separately, but it is not a claim
35//! that the provider's allowance is per-host — the vault reports a single
36//! [`calls_per_minute`](super::quota::QuotaStatus::calls_per_minute), and whether this host draws
37//! on the same pool has not been measured. Budget as though it were shared.
38//!
39//! # ⚠️ Licensing
40//! Data retrieved through this client is **not redistributable**. See the
41//! [module documentation](super) and <https://londonstrategicedge.com/terms>.
42
43use crate::exchange::lse::error::LseError;
44use crate::exchange::lse::transport::LseHttpCore;
45use std::time::Duration;
46
47/// Base URL of the data API host.
48///
49/// Note the absence of a path prefix, where the vault carries `/vault`: this host's endpoints hang
50/// directly off the root.
51const DATA_API_BASE_URL: &str = "https://data-api.londonstrategicedge.com";
52
53/// Authenticated client for the London Strategic Edge data API.
54///
55/// Wraps the internal transport shared with the vault, pointed at this host, and adds its
56/// endpoints: bond yields in [`bond_yield`](super::bond_yield).
57///
58/// # Request rationing is per client, not per call
59/// Every request this client issues first passes a gate that bounds requests in flight to
60/// [`with_concurrency`](Self::with_concurrency) and spaces their starts by
61/// [`with_pace`](Self::with_pace). That gate is **shared by clones**, so driving N concurrent
62/// fetches from one client (`Arc` it, or clone it) keeps the aggregate inside those two bounds
63/// rather than multiplying them by N. Two clients built separately by `new` share nothing and
64/// ration independently — one client per API key is the usable unit.
65///
66/// # ⚠️ Licensing
67/// Data retrieved through this client is **not redistributable**. See the
68/// [module documentation](super) and <https://londonstrategicedge.com/terms>.
69#[derive(Clone)]
70pub struct LseDataApiClient {
71    core: LseHttpCore,
72}
73
74impl std::fmt::Debug for LseDataApiClient {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        // Deliberately omits the `reqwest::Client`, and therefore its auth header, so the API key
77        // never leaks through `Debug` into logs or error reports. The core's own `Debug` redacts
78        // too; the fields are restated here so this type's output does not depend on that.
79        f.debug_struct("LseDataApiClient")
80            .field("base_url", &self.core.base_url())
81            .field("pace", &self.core.pace())
82            .field("concurrency", &self.core.concurrency())
83            .finish_non_exhaustive()
84    }
85}
86
87impl LseDataApiClient {
88    /// Create a client with an explicit API key.
89    ///
90    /// # Redirects are not followed
91    /// The key rides every request as a client-wide `x-api-key` default header, and reqwest's
92    /// cross-host header stripping covers `Authorization`, `Cookie`, `Cookie2`,
93    /// `Proxy-Authorization` and `WWW-Authenticate` — **not** a custom header. Under reqwest's
94    /// default [`Policy::limited(10)`](reqwest::redirect::Policy::limited) a single server-issued
95    /// 302 would therefore re-issue the request, live key attached, against whatever host this one
96    /// named.
97    ///
98    /// [`Policy::none`](reqwest::redirect::Policy::none) makes the "this client only ever requests
99    /// URLs it builds itself" invariant structural rather than a property of the provider's current
100    /// behaviour — a followed redirect *is* a server-supplied URL. An unexpected 3xx surfaces as a
101    /// typed error instead of being followed, and it applies **wholesale**: a same-origin 301/308
102    /// (trailing-slash normalisation by a proxy or CDN) is surfaced too, so a base URL set via
103    /// [`with_base_url`](Self::with_base_url) must serve responses directly.
104    ///
105    /// # Errors
106    /// Returns [`LseError::InvalidCredential`] if the key cannot be encoded as an HTTP header
107    /// value (e.g. non-ASCII bytes), or [`LseError::Http`] if the HTTP client cannot be built.
108    pub fn new(api_key: &str) -> Result<Self, LseError> {
109        Ok(Self {
110            core: LseHttpCore::new(api_key, DATA_API_BASE_URL)?,
111        })
112    }
113
114    /// Create a client from the `LSE_API_KEY` environment variable.
115    ///
116    /// The same variable the vault client reads — one key authenticates both hosts.
117    ///
118    /// # Errors
119    /// Returns [`LseError::EnvVar`] if the variable is unset or does not hold valid UTF-8, plus
120    /// anything [`new`](Self::new) returns. Neither message ever contains the variable's value.
121    pub fn from_env() -> Result<Self, LseError> {
122        Ok(Self {
123            core: LseHttpCore::from_env(DATA_API_BASE_URL)?,
124        })
125    }
126
127    /// Override the data API base URL, for tests against a mock server or a proxy.
128    ///
129    /// Infallible by design: this client only ever requests URLs it builds itself from this base —
130    /// the endpoints here answer with a self-contained `{count, data}` envelope carrying no cursor,
131    /// so there is never a server-supplied URL to follow and no trusted origin to derive.
132    #[must_use]
133    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
134        self.core.set_base_url(base_url);
135        self
136    }
137
138    /// Inject a pre-built [`reqwest::Client`].
139    ///
140    /// # ⚠️ The injected client must carry the `x-api-key` header itself
141    /// This replaces the authenticated client built by [`new`](Self::new), auth header included.
142    /// It is intended for transport configuration (proxy, TLS, a shared connection pool) where the
143    /// caller supplies credentials; a client without the header will see every request `401`.
144    ///
145    /// # ⚠️ It must also carry its own timeouts
146    /// [`new`](Self::new) configures a 30s total timeout and a 30s read timeout; `reqwest`'s
147    /// default is **neither**. Replacing the client drops both, and a timeout-less client
148    /// interacts badly with the [request gate](Self#request-rationing-is-per-client-not-per-call):
149    /// a permit is held for the whole request, so with the default concurrency of 2, two stalled
150    /// connections exhaust the gate *permanently* — for this client and every clone of it, with no
151    /// error, no log, and nothing to observe but a run that stops making progress. Set
152    /// [`timeout`](reqwest::ClientBuilder::timeout) and
153    /// [`read_timeout`](reqwest::ClientBuilder::read_timeout) on any client passed here.
154    ///
155    /// # ⚠️ And its own redirect policy
156    /// [`new`](Self::new) sets [`Policy::none`](reqwest::redirect::Policy::none) so the API key
157    /// cannot ride a server-issued redirect to another host; reqwest's default is
158    /// `Policy::limited(10)`, which strips only the standard auth headers and would forward a
159    /// custom `x-api-key` intact. A client supplied here that carries the key **must** set
160    /// `Policy::none` itself, or a single 302 leaks the credential with no error and no log.
161    #[must_use]
162    pub fn with_client(mut self, client: reqwest::Client) -> Self {
163        self.core.set_client(client);
164        self
165    }
166
167    /// Override the minimum delay between the starts of two requests.
168    ///
169    /// Applies to **every** request this client issues, and is enforced against the
170    /// [gate](Self#request-rationing-is-per-client-not-per-call) this client shares with its
171    /// clones — so N concurrent fetches still start one request per `pace` between them.
172    ///
173    /// Pass [`Duration::ZERO`] to disable pacing entirely, at which point staying within the
174    /// provider's allowance becomes the caller's responsibility. The default is derived from the
175    /// allowance the **vault** documents; this host publishes no rate of its own, so the vault's
176    /// figure is used rather than an unrationed default.
177    #[must_use]
178    pub fn with_pace(mut self, pace: Duration) -> Self {
179        self.core.set_pace(pace);
180        self
181    }
182
183    /// Override how many requests this client may have in flight at once.
184    ///
185    /// `0` is clamped to `1`, since a client that can never issue a request is never what was
186    /// meant.
187    ///
188    /// # ⚠️ Call this before cloning
189    /// This installs a **fresh** gate. Clones taken *before* the call keep the old one and ration
190    /// separately from this client, which defeats the point of sharing; clones taken after share
191    /// the new gate as usual.
192    #[must_use]
193    pub fn with_concurrency(mut self, concurrency: usize) -> Self {
194        self.core.set_concurrency(concurrency);
195        self
196    }
197
198    /// Issue an authenticated `GET` against a data API path and deserialise the JSON body.
199    ///
200    /// Rationed by this client's shared [gate](Self#request-rationing-is-per-client-not-per-call),
201    /// so a caller driving several of these concurrently does not have to pace them itself.
202    ///
203    /// # Errors
204    /// Maps a `429` to [`LseError::RateLimited`] (carrying `Retry-After` when present), any other
205    /// non-success status to [`LseError::Api`] with the provider's diagnostic unwrapped from its
206    /// envelope, and a body that will not decode to [`LseError::Deserialize`].
207    pub(crate) async fn get_json<T>(
208        &self,
209        path: &str,
210        query: &[(&str, String)],
211    ) -> Result<T, LseError>
212    where
213        T: serde::de::DeserializeOwned,
214    {
215        self.core.get_json(path, query).await
216    }
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
221mod tests {
222    use super::*;
223    use crate::exchange::lse::transport::API_KEY_ENV;
224    use tokio::time::Instant;
225
226    #[test]
227    fn debug_does_not_leak_the_api_key() {
228        let client = LseDataApiClient::new("super-secret-key").unwrap();
229
230        assert!(!format!("{client:?}").contains("super-secret-key"));
231    }
232
233    #[test]
234    fn debug_reports_the_data_api_configuration() {
235        let client = LseDataApiClient::new("key").unwrap();
236        let rendered = format!("{client:?}");
237
238        assert!(rendered.contains(DATA_API_BASE_URL), "{rendered}");
239        assert!(rendered.contains("concurrency: 2"), "{rendered}");
240    }
241
242    /// The core is host-agnostic, so pointing at this host is entirely this type's contribution —
243    /// a regression in it would send every bond-yield call to the vault, which answers `404` for
244    /// these paths rather than anything that looks like a mistake.
245    #[test]
246    fn new_points_at_the_data_api_not_at_the_vault() {
247        let client = LseDataApiClient::new("key").unwrap();
248
249        assert_eq!(client.core.base_url(), DATA_API_BASE_URL);
250        assert!(
251            !client.core.base_url().contains("/vault"),
252            "{}",
253            client.core.base_url()
254        );
255    }
256
257    #[test]
258    fn from_env_points_at_the_data_api() {
259        temp_env::with_var(API_KEY_ENV, Some("test-key"), || {
260            let client = LseDataApiClient::from_env().unwrap();
261
262            assert_eq!(client.core.base_url(), DATA_API_BASE_URL);
263        });
264    }
265
266    #[test]
267    fn from_env_reports_the_variable_name_when_unset() {
268        temp_env::with_var_unset(API_KEY_ENV, || {
269            let error = LseDataApiClient::from_env().unwrap_err();
270
271            assert!(matches!(error, LseError::EnvVar(_)));
272            assert!(error.to_string().contains(API_KEY_ENV));
273        });
274    }
275
276    #[test]
277    fn with_base_url_overrides_the_host() {
278        let client = LseDataApiClient::new("key")
279            .unwrap()
280            .with_base_url("http://127.0.0.1:1/mock");
281
282        assert_eq!(client.core.base_url(), "http://127.0.0.1:1/mock");
283    }
284
285    #[test]
286    fn a_key_carrying_a_control_character_is_a_typed_error_not_a_panic() {
287        for key in [
288            "key-with-\nnewline",
289            "key-with-\rcarriage",
290            "key\u{0}with-nul",
291        ] {
292            assert!(
293                matches!(
294                    LseDataApiClient::new(key).unwrap_err(),
295                    LseError::InvalidCredential(_)
296                ),
297                "{key:?} should be rejected"
298            );
299        }
300    }
301
302    /// Clones must queue together — otherwise handing a clone to each of N concurrent fetches would
303    /// restore exactly the N × rate the gate exists to prevent.
304    #[tokio::test(start_paused = true)]
305    async fn a_cloned_client_queues_at_the_same_gate_as_its_original() {
306        let pace = Duration::from_millis(300);
307        let client = LseDataApiClient::new("key").unwrap().with_pace(pace);
308        let clone = client.clone();
309        let started = Instant::now();
310
311        let _first = client.core.enter_gate().await;
312        let _second = clone.core.enter_gate().await;
313
314        assert_eq!(started.elapsed(), pace);
315    }
316
317    /// The claim the module documentation makes: a consumer driving both hosts gets the sum of the
318    /// two rates, not one shared budget.
319    #[tokio::test(start_paused = true)]
320    async fn this_client_and_a_vault_client_ration_independently() {
321        use crate::exchange::lse::vault::LseVaultClient;
322
323        let data_api = LseDataApiClient::new("key").unwrap();
324        let vault = LseVaultClient::new("key").unwrap();
325
326        let started = Instant::now();
327        let _first = data_api.core.enter_gate().await;
328        let _second = vault.enter_gate().await;
329
330        assert_eq!(started.elapsed(), Duration::ZERO);
331    }
332
333    #[test]
334    fn a_concurrency_of_zero_is_clamped_rather_than_parking_every_request_forever() {
335        let client = LseDataApiClient::new("key").unwrap().with_concurrency(0);
336
337        assert!(format!("{client:?}").contains("concurrency: 1"));
338    }
339}