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}