use crate::exchange::lse::error::LseError;
use crate::exchange::lse::transport::LseHttpCore;
use std::time::Duration;
const DATA_API_BASE_URL: &str = "https://data-api.londonstrategicedge.com";
#[derive(Clone)]
pub struct LseDataApiClient {
core: LseHttpCore,
}
impl std::fmt::Debug for LseDataApiClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LseDataApiClient")
.field("base_url", &self.core.base_url())
.field("pace", &self.core.pace())
.field("concurrency", &self.core.concurrency())
.finish_non_exhaustive()
}
}
impl LseDataApiClient {
pub fn new(api_key: &str) -> Result<Self, LseError> {
Ok(Self {
core: LseHttpCore::new(api_key, DATA_API_BASE_URL)?,
})
}
pub fn from_env() -> Result<Self, LseError> {
Ok(Self {
core: LseHttpCore::from_env(DATA_API_BASE_URL)?,
})
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.core.set_base_url(base_url);
self
}
#[must_use]
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.core.set_client(client);
self
}
#[must_use]
pub fn with_pace(mut self, pace: Duration) -> Self {
self.core.set_pace(pace);
self
}
#[must_use]
pub fn with_concurrency(mut self, concurrency: usize) -> Self {
self.core.set_concurrency(concurrency);
self
}
pub(crate) async fn get_json<T>(
&self,
path: &str,
query: &[(&str, String)],
) -> Result<T, LseError>
where
T: serde::de::DeserializeOwned,
{
self.core.get_json(path, query).await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
use crate::exchange::lse::transport::API_KEY_ENV;
use tokio::time::Instant;
#[test]
fn debug_does_not_leak_the_api_key() {
let client = LseDataApiClient::new("super-secret-key").unwrap();
assert!(!format!("{client:?}").contains("super-secret-key"));
}
#[test]
fn debug_reports_the_data_api_configuration() {
let client = LseDataApiClient::new("key").unwrap();
let rendered = format!("{client:?}");
assert!(rendered.contains(DATA_API_BASE_URL), "{rendered}");
assert!(rendered.contains("concurrency: 2"), "{rendered}");
}
#[test]
fn new_points_at_the_data_api_not_at_the_vault() {
let client = LseDataApiClient::new("key").unwrap();
assert_eq!(client.core.base_url(), DATA_API_BASE_URL);
assert!(
!client.core.base_url().contains("/vault"),
"{}",
client.core.base_url()
);
}
#[test]
fn from_env_points_at_the_data_api() {
temp_env::with_var(API_KEY_ENV, Some("test-key"), || {
let client = LseDataApiClient::from_env().unwrap();
assert_eq!(client.core.base_url(), DATA_API_BASE_URL);
});
}
#[test]
fn from_env_reports_the_variable_name_when_unset() {
temp_env::with_var_unset(API_KEY_ENV, || {
let error = LseDataApiClient::from_env().unwrap_err();
assert!(matches!(error, LseError::EnvVar(_)));
assert!(error.to_string().contains(API_KEY_ENV));
});
}
#[test]
fn with_base_url_overrides_the_host() {
let client = LseDataApiClient::new("key")
.unwrap()
.with_base_url("http://127.0.0.1:1/mock");
assert_eq!(client.core.base_url(), "http://127.0.0.1:1/mock");
}
#[test]
fn a_key_carrying_a_control_character_is_a_typed_error_not_a_panic() {
for key in [
"key-with-\nnewline",
"key-with-\rcarriage",
"key\u{0}with-nul",
] {
assert!(
matches!(
LseDataApiClient::new(key).unwrap_err(),
LseError::InvalidCredential(_)
),
"{key:?} should be rejected"
);
}
}
#[tokio::test(start_paused = true)]
async fn a_cloned_client_queues_at_the_same_gate_as_its_original() {
let pace = Duration::from_millis(300);
let client = LseDataApiClient::new("key").unwrap().with_pace(pace);
let clone = client.clone();
let started = Instant::now();
let _first = client.core.enter_gate().await;
let _second = clone.core.enter_gate().await;
assert_eq!(started.elapsed(), pace);
}
#[tokio::test(start_paused = true)]
async fn this_client_and_a_vault_client_ration_independently() {
use crate::exchange::lse::vault::LseVaultClient;
let data_api = LseDataApiClient::new("key").unwrap();
let vault = LseVaultClient::new("key").unwrap();
let started = Instant::now();
let _first = data_api.core.enter_gate().await;
let _second = vault.enter_gate().await;
assert_eq!(started.elapsed(), Duration::ZERO);
}
#[test]
fn a_concurrency_of_zero_is_clamped_rather_than_parking_every_request_forever() {
let client = LseDataApiClient::new("key").unwrap().with_concurrency(0);
assert!(format!("{client:?}").contains("concurrency: 1"));
}
}