use crate::exchange::lse::error::LseError;
use crate::exchange::lse::quota::QuotaStatus;
use crate::exchange::lse::transport::LseHttpCore;
use std::{num::NonZeroU32, time::Duration};
use tokio::sync::SemaphorePermit;
const VAULT_BASE_URL: &str = "https://api.londonstrategicedge.com/vault";
const DEFAULT_PAGE_LIMIT: NonZeroU32 = NonZeroU32::new(5000).unwrap();
#[derive(Clone)]
pub struct LseVaultClient {
core: LseHttpCore,
page_limit: NonZeroU32,
}
impl std::fmt::Debug for LseVaultClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LseVaultClient")
.field("base_url", &self.core.base_url())
.field("pace", &self.core.pace())
.field("page_limit", &self.page_limit)
.field("concurrency", &self.core.concurrency())
.finish_non_exhaustive()
}
}
impl LseVaultClient {
pub fn new(api_key: &str) -> Result<Self, LseError> {
Ok(Self {
core: LseHttpCore::new(api_key, VAULT_BASE_URL)?,
page_limit: DEFAULT_PAGE_LIMIT,
})
}
pub fn from_env() -> Result<Self, LseError> {
Ok(Self {
core: LseHttpCore::from_env(VAULT_BASE_URL)?,
page_limit: DEFAULT_PAGE_LIMIT,
})
}
#[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_page_limit(mut self, limit: NonZeroU32) -> Self {
self.page_limit = limit;
self
}
#[must_use]
pub fn with_concurrency(mut self, concurrency: usize) -> Self {
self.core.set_concurrency(concurrency);
self
}
pub(crate) async fn enter_gate(&self) -> Option<SemaphorePermit<'_>> {
self.core.enter_gate().await
}
pub(crate) fn base_url(&self) -> &str {
self.core.base_url()
}
pub(crate) fn page_limit(&self) -> NonZeroU32 {
self.page_limit
}
pub(crate) fn http(&self) -> &reqwest::Client {
self.core.http()
}
pub async fn usage(&self) -> Result<QuotaStatus, LseError> {
self.get_json("usage", &[]).await
}
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 = LseVaultClient::new("super-secret-key").unwrap();
assert!(!format!("{client:?}").contains("super-secret-key"));
}
#[test]
fn debug_reports_the_vault_configuration() {
let client = LseVaultClient::new("key").unwrap();
let rendered = format!("{client:?}");
assert!(rendered.contains(VAULT_BASE_URL), "{rendered}");
assert!(rendered.contains("page_limit: 5000"), "{rendered}");
assert!(rendered.contains("concurrency: 2"), "{rendered}");
}
#[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!(
LseVaultClient::new(key).unwrap_err(),
LseError::InvalidCredential(_)
),
"{key:?} should be rejected"
);
}
}
#[test]
fn from_env_reports_the_variable_name_when_unset() {
temp_env::with_var_unset(API_KEY_ENV, || {
let error = LseVaultClient::from_env().unwrap_err();
assert!(matches!(error, LseError::EnvVar(_)));
assert!(error.to_string().contains(API_KEY_ENV));
});
}
#[test]
fn from_env_builds_a_client_when_set() {
temp_env::with_var(API_KEY_ENV, Some("test-key"), || {
assert!(LseVaultClient::from_env().is_ok());
});
}
#[test]
fn from_env_points_at_the_vault_not_at_whatever_the_core_defaults_to() {
temp_env::with_var(API_KEY_ENV, Some("test-key"), || {
let client = LseVaultClient::from_env().unwrap();
assert_eq!(client.base_url(), VAULT_BASE_URL);
});
}
#[test]
fn new_points_at_the_vault() {
assert_eq!(
LseVaultClient::new("key").unwrap().base_url(),
VAULT_BASE_URL
);
}
#[test]
fn with_base_url_overrides_the_host() {
let client = LseVaultClient::new("key")
.unwrap()
.with_base_url("http://127.0.0.1:1/mock");
assert_eq!(client.base_url(), "http://127.0.0.1:1/mock");
}
#[test]
fn with_page_limit_is_carried_independently_of_the_shared_core() {
let limit = NonZeroU32::new(17).unwrap();
let client = LseVaultClient::new("key").unwrap().with_page_limit(limit);
assert_eq!(client.page_limit(), limit);
assert!(format!("{client:?}").contains("page_limit: 17"));
}
#[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 = LseVaultClient::new("key").unwrap().with_pace(pace);
let clone = client.clone();
let started = Instant::now();
let _first = client.enter_gate().await;
let _second = clone.enter_gate().await;
assert_eq!(started.elapsed(), pace);
}
#[test]
fn a_concurrency_of_zero_is_clamped_rather_than_parking_every_request_forever() {
let client = LseVaultClient::new("key").unwrap().with_concurrency(0);
assert!(format!("{client:?}").contains("concurrency: 1"));
}
}