ticksupply 0.2.1

Official Rust client for the Ticksupply market data API
Documentation
//! client — The [`Client`] entry point and its [`ClientBuilder`].

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"));

/// The Ticksupply API client.
///
/// Construct via [`Client::new`], [`Client::with_api_key`], or
/// [`Client::builder`]. `Client` is cheap to clone — cloning bumps an
/// internal [`Arc`] and shares the underlying connection pool.
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> ticksupply::Result<()> {
/// // Reads TICKSUPPLY_API_KEY from the environment.
/// let client = ticksupply::Client::new()?;
/// # let _ = client;
/// # Ok(()) }
/// ```
#[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 {
    /// Constructs a client using the `TICKSUPPLY_API_KEY` environment variable.
    ///
    /// # Errors
    ///
    /// - [`Error::Config`] if `TICKSUPPLY_API_KEY` is unset or empty.
    /// - [`Error::Config`] if the internal HTTP client fails to build.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn new() -> Result<Self> {
        Self::builder().build()
    }

    /// Constructs a client with an explicit API key.
    ///
    /// # Errors
    ///
    /// - [`Error::Config`] if `api_key` is empty or contains invalid header bytes.
    /// - [`Error::Config`] if the internal HTTP client fails to build.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::with_api_key("key_abc.secret")?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
        Self::builder().api_key(api_key).build()
    }

    /// Returns a [`ClientBuilder`] for configuring the client.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// use std::time::Duration;
    /// let client = ticksupply::Client::builder()
    ///     .api_key("key_abc.secret")
    ///     .timeout(Duration::from_secs(60))
    ///     .build()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }
}

/// Builder for [`Client`].
///
/// # Examples
///
/// ```no_run
/// # fn example() -> ticksupply::Result<()> {
/// use std::time::Duration;
/// let client = ticksupply::Client::builder()
///     .api_key("key_abc.secret")
///     .base_url("https://api.ticksupply.com/v1")
///     .timeout(Duration::from_secs(60))
///     .max_retries(5)
///     .build()?;
/// # let _ = client;
/// # Ok(()) }
/// ```
#[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 {
    /// Sets the API key.
    ///
    /// If unset at [`build`](Self::build) time, the `TICKSUPPLY_API_KEY`
    /// environment variable is used.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::builder()
    ///     .api_key("key_abc.secret")
    ///     .build()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn api_key(mut self, key: impl Into<String>) -> Self {
        self.api_key = Some(key.into());
        self
    }

    /// Overrides the base URL (default: `https://api.ticksupply.com/v1`).
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::builder()
    ///     .api_key("key_abc.secret")
    ///     .base_url("https://api-staging.ticksupply.com/v1")
    ///     .build()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = Some(url.into());
        self
    }

    /// Overrides the per-request timeout (default: 30 s).
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// use std::time::Duration;
    /// let client = ticksupply::Client::builder()
    ///     .api_key("key_abc.secret")
    ///     .timeout(Duration::from_secs(10))
    ///     .build()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    /// Overrides the maximum retry count (default: 3). Zero disables retries.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::builder()
    ///     .api_key("key_abc.secret")
    ///     .max_retries(0)
    ///     .build()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn max_retries(mut self, n: u32) -> Self {
        self.max_retries = Some(n);
        self
    }

    /// Appends a custom suffix to the `User-Agent` header.
    ///
    /// The prefix `ticksupply-rust/<version>` is always included.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::builder()
    ///     .api_key("key_abc.secret")
    ///     .user_agent("my-bot/1.0")
    ///     .build()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
        self.user_agent = Some(ua.into());
        self
    }

    /// Installs a caller-supplied `reqwest::Client` for proxies, custom TLS,
    /// or telemetry layers.
    ///
    /// The supplied client governs its own timeout and user-agent; the
    /// `X-Api-Key` header is attached by this crate on every request.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// let http = reqwest::Client::builder().build().expect("http client");
    /// let client = ticksupply::Client::builder()
    ///     .api_key("key_abc.secret")
    ///     .http_client(http)
    ///     .build()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    pub fn http_client(mut self, client: reqwest::Client) -> Self {
        self.http_client = Some(client);
        self
    }

    /// Finalizes the builder and returns a [`Client`].
    ///
    /// # Errors
    ///
    /// - [`Error::Config`] if no API key is supplied and `TICKSUPPLY_API_KEY` is unset or empty.
    /// - [`Error::Config`] if the API key contains invalid header bytes.
    /// - [`Error::Config`] if the internal `reqwest::Client` fails to build.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::builder()
    ///     .api_key("key_abc.secret")
    ///     .build()?;
    /// # let _ = client;
    /// # Ok(()) }
    /// ```
    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() {
        // Clear the env var for the duration of this test.
        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");
    }
}