libfmp 0.2.0

Async, typed Rust client for the Financial Modeling Prep API
Documentation
mod support;

use std::{str::FromStr, sync::Arc};

use libfmp::{
    Client,
    config::Authentication,
    endpoints::{
        EndpointSpec,
        chart::{
            StockChartEodQuery, stock_chart_dividend_adjusted, stock_chart_non_split_adjusted,
        },
        metadata::{AccessRequirement, EndpointBounds, GeographicAvailability},
    },
    transport::HttpMethod,
    types::{Date, Ticker},
};

use support::{FixtureExecutor, json_fixture};

const NON_SPLIT: &[u8] = include_bytes!("fixtures/stock_chart_non_split_adjusted.json");
const DIVIDEND: &[u8] = include_bytes!("fixtures/stock_chart_dividend_adjusted.json");

#[test]
fn descriptors_use_exact_paths_shared_adjusted_rows_and_documented_metadata() {
    let symbol = Ticker::new("AAPL").unwrap();
    let query = StockChartEodQuery::new(symbol.clone())
        .with_from(Date::from_str("2026-04-30").unwrap())
        .with_to(Date::from_str("2026-07-30").unwrap());

    assert_eq!(query.symbol(), &symbol);
    assert_eq!(query.from().unwrap().to_string(), "2026-04-30");
    assert_eq!(query.to().unwrap().to_string(), "2026-07-30");
    assert_facts(
        &stock_chart_non_split_adjusted(query.clone()),
        "historical-price-eod/non-split-adjusted",
    );
    assert_facts(
        &stock_chart_dividend_adjusted(query),
        "historical-price-eod/dividend-adjusted",
    );
}

fn assert_facts<Q, R>(endpoint: &EndpointSpec<Q, R>, path: &'static str) {
    assert_eq!(endpoint.method(), HttpMethod::Get);
    assert_eq!(endpoint.id(), path);
    assert_eq!(endpoint.relative_path(), path);
    assert_eq!(
        endpoint.metadata().geography(),
        GeographicAvailability::Worldwide
    );
    assert_eq!(endpoint.metadata().access(), AccessRequirement::Unspecified);
    assert_eq!(
        endpoint.metadata().bounds(),
        EndpointBounds::new().with_response_rows(5_000)
    );
    assert_eq!(endpoint.metadata().conditional_plan(), None);
    assert_eq!(endpoint.metadata().realtime(), None);
}

#[tokio::test]
async fn custom_proxy_preserves_base_path_query_order_auth_headers_and_bare_arrays() {
    let executor = Arc::new(FixtureExecutor::new([
        json_fixture(NON_SPLIT),
        json_fixture(DIVIDEND),
    ]));
    let client = Client::builder()
        .base_url("https://proxy.example/router")
        .path_prefix("stable")
        .authentication(Authentication::custom_header(
            "x-router-token",
            Some("Token ".to_owned()),
            "proxy-secret",
        ))
        .default_header("x-data-scope", "chart-adjusted")
        .executor(executor.clone())
        .build()
        .unwrap();
    let symbol = Ticker::new("BRK.B / Class A").unwrap();
    let from = Date::from_str("2026-04-30").unwrap();
    let to = Date::from_str("2026-07-30").unwrap();

    let non_split = client
        .stock_chart_non_split_adjusted(
            StockChartEodQuery::new(symbol.clone())
                .with_from(from)
                .with_to(to),
        )
        .await
        .unwrap();
    let dividend = client
        .stock_chart_dividend_adjusted(StockChartEodQuery::new(symbol).with_from(from).with_to(to))
        .await
        .unwrap();

    assert_eq!(non_split.len(), 1);
    assert_eq!(non_split[0].adj_open, 333.13);
    assert_eq!(non_split[0].adj_close, 332.39);
    assert_eq!(dividend.len(), 1);
    assert_eq!(dividend[0].adj_high, 334.48);
    assert_eq!(dividend[0].adj_low, 329.59);

    let requests = executor.requests();
    assert!(
        requests
            .iter()
            .all(|request| request.method() == HttpMethod::Get)
    );
    assert!(requests.iter().all(|request| {
        request.expose_headers()["x-router-token"] == "Token proxy-secret"
            && request.expose_headers()["x-data-scope"] == "chart-adjusted"
    }));
    assert_eq!(
        requests
            .iter()
            .map(|request| request.expose_url().as_str())
            .collect::<Vec<_>>(),
        [
            "https://proxy.example/router/stable/historical-price-eod/non-split-adjusted?symbol=BRK.B+%2F+Class+A&from=2026-04-30&to=2026-07-30",
            "https://proxy.example/router/stable/historical-price-eod/dividend-adjusted?symbol=BRK.B+%2F+Class+A&from=2026-04-30&to=2026-07-30",
        ]
    );
}

#[tokio::test]
async fn direct_header_and_query_auth_apply_to_both_adjusted_eod_contracts() {
    for (authentication, query_suffix, expected_header) in [
        (
            Authentication::fmp_header("header-secret"),
            "",
            Some("header-secret"),
        ),
        (
            Authentication::fmp_query("query-secret"),
            "&apikey=query-secret",
            None,
        ),
    ] {
        let executor = Arc::new(FixtureExecutor::new([
            json_fixture(NON_SPLIT),
            json_fixture(DIVIDEND),
        ]));
        let client = Client::builder()
            .authentication(authentication)
            .executor(executor.clone())
            .build()
            .unwrap();
        let symbol = Ticker::new("AAPL").unwrap();

        client
            .stock_chart_non_split_adjusted(
                StockChartEodQuery::new(symbol.clone())
                    .with_from(Date::from_str("2026-04-30").unwrap()),
            )
            .await
            .unwrap();
        client
            .stock_chart_dividend_adjusted(
                StockChartEodQuery::new(symbol).with_to(Date::from_str("2026-07-30").unwrap()),
            )
            .await
            .unwrap();

        let requests = executor.requests();
        assert_eq!(
            requests
                .iter()
                .map(|request| request.expose_url().as_str())
                .collect::<Vec<_>>(),
            [
                format!(
                    "https://financialmodelingprep.com/stable/historical-price-eod/non-split-adjusted?symbol=AAPL&from=2026-04-30{query_suffix}"
                ),
                format!(
                    "https://financialmodelingprep.com/stable/historical-price-eod/dividend-adjusted?symbol=AAPL&to=2026-07-30{query_suffix}"
                ),
            ]
        );
        for request in requests.iter() {
            match expected_header {
                Some(value) => assert_eq!(request.expose_headers()["apikey"], value),
                None => assert!(!request.expose_headers().contains_key("apikey")),
            }
        }
    }
}