use std::sync::Arc;
use std::time::Duration;
use crate::error::{Error, Result};
const DEFAULT_BASE_URL: &str = "https://api.ticksupply.com/v1";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_MAX_RETRIES: u32 = 3;
const ENV_API_KEY: &str = "TICKSUPPLY_API_KEY";
const USER_AGENT_PREFIX: &str = concat!("ticksupply-rust/", env!("CARGO_PKG_VERSION"));
#[derive(Clone)]
pub struct Client {
pub(crate) inner: Arc<Inner>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.inner.base_url)
.field("max_retries", &self.inner.max_retries)
.finish()
}
}
pub(crate) struct Inner {
pub(crate) http: reqwest::Client,
pub(crate) base_url: String,
pub(crate) max_retries: u32,
pub(crate) api_key_header: reqwest::header::HeaderValue,
}
impl Client {
pub fn new() -> Result<Self> {
Self::builder().build()
}
pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
Self::builder().api_key(api_key).build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
}
#[derive(Default)]
pub struct ClientBuilder {
api_key: Option<String>,
base_url: Option<String>,
timeout: Option<Duration>,
max_retries: Option<u32>,
user_agent: Option<String>,
http_client: Option<reqwest::Client>,
}
impl ClientBuilder {
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn max_retries(mut self, n: u32) -> Self {
self.max_retries = Some(n);
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn build(self) -> Result<Client> {
let api_key = self
.api_key
.or_else(|| std::env::var(ENV_API_KEY).ok())
.filter(|k| !k.is_empty())
.ok_or_else(|| {
Error::Config(format!(
"API key is required; set {ENV_API_KEY} or call .api_key(...)"
))
})?;
let base_url = self
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
let max_retries = self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES);
let user_agent = match self.user_agent {
Some(suffix) => format!("{USER_AGENT_PREFIX} {suffix}"),
None => USER_AGENT_PREFIX.to_string(),
};
let mut api_key_header = reqwest::header::HeaderValue::from_str(&api_key)
.map_err(|_| Error::Config("API key contains invalid header bytes".into()))?;
api_key_header.set_sensitive(true);
let http = match self.http_client {
Some(c) => c,
None => reqwest::Client::builder()
.user_agent(user_agent)
.timeout(timeout)
.build()
.map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?,
};
Ok(Client {
inner: Arc::new(Inner {
http,
base_url: base_url.trim_end_matches('/').to_string(),
max_retries,
api_key_header,
}),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_errors_without_api_key() {
let prev = std::env::var(ENV_API_KEY).ok();
std::env::remove_var(ENV_API_KEY);
let err = ClientBuilder::default().build().unwrap_err();
match err {
Error::Config(msg) => assert!(msg.contains("API key is required")),
other => panic!("wrong error: {other:?}"),
}
if let Some(v) = prev {
std::env::set_var(ENV_API_KEY, v);
}
}
#[test]
fn builder_accepts_explicit_key() {
let client = ClientBuilder::default()
.api_key("key_test.secret")
.build()
.unwrap();
assert_eq!(client.inner.base_url, DEFAULT_BASE_URL);
assert_eq!(client.inner.max_retries, DEFAULT_MAX_RETRIES);
}
#[test]
fn builder_trims_trailing_slash_from_base_url() {
let client = ClientBuilder::default()
.api_key("k")
.base_url("https://example.com/v1/")
.build()
.unwrap();
assert_eq!(client.inner.base_url, "https://example.com/v1");
}
}