alpaca-data 0.10.2

High-performance Rust client for Alpaca Market Data API
Documentation
use std::time::Duration;
use std::{ffi::OsString, sync::OnceLock};

use alpaca_data::{Client, Error, news};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[test]
fn builder_allows_public_crypto_only_clients() {
    let client = Client::builder()
        .build()
        .expect("public crypto client should build");

    let _ = client.crypto();
}

#[test]
fn builder_rejects_partial_credentials() {
    let error = Client::builder()
        .api_key("key-only")
        .build()
        .expect_err("partial credentials must fail");

    assert!(matches!(
        error,
        Error::InvalidConfiguration(message)
            if message.contains("api_key") && message.contains("secret_key")
    ));
}

#[test]
fn builder_accepts_explicit_shared_runtime_settings() {
    let client = Client::builder()
        .api_key("key")
        .secret_key("secret")
        .base_url("https://data.alpaca.markets")
        .timeout(Duration::from_secs(5))
        .max_retries(2)
        .max_in_flight(32)
        .build()
        .expect("configured client should build");

    let _ = client.stocks();
}

#[test]
fn builder_accepts_structured_retry_settings() {
    let client = Client::builder()
        .base_url("https://data.alpaca.markets")
        .max_retries(2)
        .retry_on_429(true)
        .respect_retry_after(true)
        .base_backoff(Duration::from_millis(10))
        .max_backoff(Duration::from_millis(20))
        .retry_jitter(Duration::from_millis(5))
        .total_retry_budget(Duration::from_millis(50))
        .build()
        .expect("configured retry client should build");

    let _ = client.crypto();
}

#[test]
fn builder_accepts_custom_reqwest_client() {
    let reqwest_client = reqwest::Client::builder()
        .build()
        .expect("custom reqwest client should build");

    let client = Client::builder()
        .reqwest_client(reqwest_client)
        .build()
        .expect("client should build with injected reqwest client");

    let _ = client.crypto();
}

#[test]
fn builder_rejects_timeout_when_custom_reqwest_client_is_injected() {
    let reqwest_client = reqwest::Client::builder()
        .build()
        .expect("custom reqwest client should build");

    let error = Client::builder()
        .timeout(Duration::from_secs(5))
        .reqwest_client(reqwest_client)
        .build()
        .expect_err("timeout should conflict with injected reqwest client");

    assert!(matches!(
        error,
        Error::InvalidConfiguration(message)
            if message.contains("reqwest_client") && message.contains("timeout")
    ));
}

#[tokio::test]
async fn custom_reqwest_client_can_be_used_with_retry_controls() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .and(header("x-custom-client", "phase3"))
        .respond_with(ResponseTemplate::new(500).set_body_string("server error"))
        .up_to_n_times(1)
        .expect(1)
        .mount(&server)
        .await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .and(header("x-custom-client", "phase3"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            r#"{"quotes":{"BTC/USD":{"ap":67005.5,"as":1.26733,"bp":66894.8,"bs":2.56753,"t":"2026-04-04T00:00:04.184229364Z"}}}"#,
            "application/json",
        ))
        .expect(1)
        .mount(&server)
        .await;

    let reqwest_client = reqwest::Client::builder()
        .default_headers(
            [(
                reqwest::header::HeaderName::from_static("x-custom-client"),
                reqwest::header::HeaderValue::from_static("phase3"),
            )]
            .into_iter()
            .collect(),
        )
        .build()
        .expect("custom reqwest client should build");

    let response = Client::builder()
        .base_url(server.uri())
        .reqwest_client(reqwest_client)
        .max_retries(1)
        .base_backoff(Duration::from_millis(1))
        .max_backoff(Duration::from_millis(1))
        .build()
        .expect("client should build with injected reqwest client")
        .crypto()
        .latest_quotes(alpaca_data::crypto::LatestQuotesRequest {
            symbols: vec!["BTC/USD".into()],
            loc: Some(alpaca_data::crypto::Loc::Us),
        })
        .await
        .expect("request should succeed after retry");

    assert!(response.quotes.contains_key("BTC/USD"));
}

fn env_test_lock() -> &'static std::sync::Mutex<()> {
    static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| std::sync::Mutex::new(()))
}

struct EnvGuard {
    entries: Vec<(String, Option<OsString>)>,
}

impl EnvGuard {
    fn set(vars: &[(&str, Option<&str>)]) -> Self {
        let entries = vars
            .iter()
            .map(|(name, value)| {
                let previous = std::env::var_os(name);
                match value {
                    Some(value) => unsafe { std::env::set_var(name, value) },
                    None => unsafe { std::env::remove_var(name) },
                }
                (name.to_string(), previous)
            })
            .collect();

        Self { entries }
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        for (name, value) in self.entries.drain(..).rev() {
            match value {
                Some(value) => unsafe { std::env::set_var(&name, value) },
                None => unsafe { std::env::remove_var(&name) },
            }
        }
    }
}

fn news_success_body() -> &'static str {
    r#"{"news":[{"id":24843171,"headline":"Apple headline","author":"Charles Gross","created_at":"2021-12-31T11:08:42Z","updated_at":"2021-12-31T11:08:43Z","summary":"Summary","content":"","url":"https://example.com/article","images":[],"symbols":["AAPL"],"source":"benzinga"}],"next_page_token":null}"#
}

#[tokio::test]
async fn credentials_from_env_loads_default_apca_names() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1beta1/news"))
        .and(header("APCA-API-KEY-ID", "env-key"))
        .and(header("APCA-API-SECRET-KEY", "env-secret"))
        .respond_with(
            ResponseTemplate::new(200).set_body_raw(news_success_body(), "application/json"),
        )
        .mount(&server)
        .await;

    let client = {
        let _lock = env_test_lock()
            .lock()
            .expect("env lock should be available");
        let _env = EnvGuard::set(&[
            ("APCA_API_KEY_ID", Some("env-key")),
            ("APCA_API_SECRET_KEY", Some("env-secret")),
        ]);

        Client::builder()
            .base_url(server.uri())
            .credentials_from_env()
            .expect("env credentials should load")
            .build()
            .expect("client should build")
    };

    let response = client
        .news()
        .list(news::ListRequest::default())
        .await
        .expect("news request should succeed");

    assert_eq!(response.news.len(), 1);
}

#[tokio::test]
async fn credentials_from_env_names_load_custom_variable_names() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1beta1/news"))
        .and(header("APCA-API-KEY-ID", "custom-key"))
        .and(header("APCA-API-SECRET-KEY", "custom-secret"))
        .respond_with(
            ResponseTemplate::new(200).set_body_raw(news_success_body(), "application/json"),
        )
        .mount(&server)
        .await;

    let client = {
        let _lock = env_test_lock()
            .lock()
            .expect("env lock should be available");
        let _env = EnvGuard::set(&[
            ("PHASE4_API_KEY", Some("custom-key")),
            ("PHASE4_SECRET_KEY", Some("custom-secret")),
        ]);

        Client::builder()
            .base_url(server.uri())
            .credentials_from_env_names("PHASE4_API_KEY", "PHASE4_SECRET_KEY")
            .expect("custom env credentials should load")
            .build()
            .expect("client should build")
    };

    let response = client
        .news()
        .list(news::ListRequest::default())
        .await
        .expect("news request should succeed");

    assert_eq!(response.news.len(), 1);
}

#[test]
fn credentials_from_env_reject_partial_values() {
    let _lock = env_test_lock()
        .lock()
        .expect("env lock should be available");
    let _env = EnvGuard::set(&[
        ("APCA_API_KEY_ID", Some("env-key")),
        ("APCA_API_SECRET_KEY", None),
    ]);

    let error = Client::builder()
        .credentials_from_env()
        .expect_err("partial env credentials must fail");

    assert!(matches!(
        error,
        Error::InvalidConfiguration(message)
            if message.contains("APCA_API_KEY_ID") && message.contains("APCA_API_SECRET_KEY")
    ));
}