alpaca-core 0.25.0

Shared primitives for the alpaca-rust workspace
Documentation
use std::str::FromStr;

use alpaca_core::{
    BaseUrl, Credentials, Error, QueryWriter, decimal,
    pagination::{PaginatedRequest, PaginatedResponse, collect_all},
};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};

#[test]
fn credentials_redact_debug_output_and_reject_invalid_header_values() {
    let credentials = Credentials::new("key", "secret").expect("valid credentials");
    assert_eq!(
        format!("{credentials:?}"),
        "Credentials { api_key: \"[REDACTED]\", secret_key: \"[REDACTED]\" }"
    );

    let error = Credentials::new("bad\nkey", "secret").expect_err("newline must be rejected");
    assert_eq!(
        error,
        Error::InvalidConfiguration("api_key must be a valid HTTP header value".to_owned())
    );
}

#[test]
fn base_url_trims_trailing_slashes_and_joins_paths_cleanly() {
    let base_url = BaseUrl::new("https://example.com/v2/").expect("valid base url");

    assert_eq!(base_url.as_str(), "https://example.com/v2");
    assert_eq!(
        base_url.join_path("/orders"),
        "https://example.com/v2/orders"
    );
    assert_eq!(
        base_url.join_path("positions"),
        "https://example.com/v2/positions"
    );
}

#[test]
fn query_writer_skips_missing_values_and_empty_csv_collections() {
    let mut writer = QueryWriter::default();
    writer.push("symbol", "AAPL");
    writer.push_opt("page_token", Option::<String>::None);
    writer.push_opt("limit", Some(50));
    writer.push_csv("symbols", ["AAPL", "MSFT"]);
    writer.push_csv("empty", Vec::<String>::new());

    assert_eq!(
        writer.finish(),
        vec![
            ("symbol".to_owned(), "AAPL".to_owned()),
            ("limit".to_owned(), "50".to_owned()),
            ("symbols".to_owned(), "AAPL,MSFT".to_owned()),
        ]
    );
}

#[test]
fn decimal_helpers_round_and_serialize_canonical_contracts() {
    #[derive(Debug, Serialize, Deserialize, PartialEq)]
    struct Payload {
        #[serde(with = "decimal::price_string_contract")]
        price: Decimal,
        #[serde(with = "decimal::number_contract::option_decimal")]
        optional: Option<Decimal>,
    }

    let payload: Payload =
        serde_json::from_str(r#"{"price":"1.239","optional":2.5}"#).expect("valid payload");

    assert_eq!(payload.price, Decimal::from_str("1.24").unwrap());
    assert_eq!(payload.optional, Some(Decimal::from_str("2.5").unwrap()));
    assert_eq!(decimal::from_f64(f64::INFINITY, 2), Decimal::ZERO);
    assert_eq!(
        decimal::parse_json_decimal(Some(&serde_json::json!("3.1415"))),
        Some(Decimal::from_str("3.1415").unwrap())
    );

    let json = serde_json::to_value(&payload).expect("payload serializes");
    assert_eq!(json["price"], "1.24");
    assert_eq!(json["optional"], 2.5);
}

#[derive(Clone, Debug)]
struct MockRequest {
    page_token: Option<String>,
}

impl PaginatedRequest for MockRequest {
    fn with_page_token(&self, page_token: Option<String>) -> Self {
        Self { page_token }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct MockResponse {
    items: Vec<&'static str>,
    next_page_token: Option<String>,
}

impl PaginatedResponse for MockResponse {
    fn next_page_token(&self) -> Option<&str> {
        self.next_page_token.as_deref()
    }

    fn merge_page(&mut self, next: Self) -> Result<(), Error> {
        self.items.extend(next.items);
        self.next_page_token = next.next_page_token;
        Ok(())
    }

    fn clear_next_page_token(&mut self) {
        self.next_page_token = None;
    }
}

#[tokio::test]
async fn collect_all_merges_pages_and_clears_the_terminal_token() {
    let response = collect_all(MockRequest { page_token: None }, |request| async move {
        Ok(match request.page_token.as_deref() {
            None => MockResponse {
                items: vec!["page-1"],
                next_page_token: Some("page-2".to_owned()),
            },
            Some("page-2") => MockResponse {
                items: vec!["page-2"],
                next_page_token: Some("page-3".to_owned()),
            },
            Some("page-3") => MockResponse {
                items: vec!["page-3"],
                next_page_token: None,
            },
            Some(other) => panic!("unexpected page token: {other}"),
        })
    })
    .await
    .expect("pagination must succeed");

    assert_eq!(response.items, vec!["page-1", "page-2", "page-3"]);
    assert_eq!(response.next_page_token, None);
}

#[tokio::test]
async fn collect_all_rejects_repeated_next_page_tokens() {
    let error = collect_all(MockRequest { page_token: None }, |request| async move {
        Ok(match request.page_token.as_deref() {
            None => MockResponse {
                items: vec!["page-1"],
                next_page_token: Some("page-2".to_owned()),
            },
            Some("page-2") => MockResponse {
                items: vec!["page-2"],
                next_page_token: Some("page-2".to_owned()),
            },
            Some(other) => panic!("unexpected page token: {other}"),
        })
    })
    .await
    .expect_err("repeated page tokens must fail");

    assert_eq!(
        error,
        Error::InvalidRequest(
            "pagination contract violation: repeated next_page_token `page-2`".to_owned()
        )
    );
}