libfmp 0.2.0

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

use std::sync::Arc;

use libfmp::{
    Client,
    config::Authentication,
    endpoints::{
        EndpointSpec,
        metadata::{AccessRequirement, EndpointBounds, GeographicAvailability},
        statements::{
            EnterpriseValuesQuery, FinancialScoresQuery, LatestFinancialStatementsQuery,
            OwnerEarningsQuery, enterprise_values, financial_scores, latest_financial_statements,
            owner_earnings,
        },
    },
    query::{FiscalPeriod, RetrievalFrequency},
    transport::HttpMethod,
    types::{Limit, Page, Ticker},
};

use support::{FixtureExecutor, json_fixture};

const LATEST: &[u8] = include_bytes!("fixtures/latest_financial_statements.json");
const SCORES: &[u8] = include_bytes!("fixtures/financial_scores.json");
const OWNER: &[u8] = include_bytes!("fixtures/owner_earnings.json");
const ENTERPRISE: &[u8] = include_bytes!("fixtures/enterprise_values.json");

#[test]
fn descriptors_use_exact_get_paths_and_only_documented_metadata() {
    let symbol = Ticker::new("AAPL").unwrap();

    let latest = latest_financial_statements(
        LatestFinancialStatementsQuery::new()
            .with_page(Page(0))
            .with_limit(Limit(250)),
    );
    assert_facts(
        &latest,
        "latest-financial-statements",
        EndpointBounds::new().with_response_rows(250).with_page(100),
    );
    assert!(latest.metadata().bounds().accepts_page(Page(0)));
    assert!(latest.metadata().bounds().accepts_page(Page(100)));
    assert!(!latest.metadata().bounds().accepts_page(Page(101)));
    assert!(latest.metadata().bounds().accepts_response_rows(250));
    assert!(!latest.metadata().bounds().accepts_response_rows(251));
    assert_facts(
        &financial_scores(FinancialScoresQuery::new(symbol.clone())),
        "financial-scores",
        EndpointBounds::new(),
    );
    assert_facts(
        &owner_earnings(OwnerEarningsQuery::new(symbol.clone())),
        "owner-earnings",
        EndpointBounds::new(),
    );
    assert_facts(
        &enterprise_values(
            EnterpriseValuesQuery::new(symbol)
                .with_limit(Limit(1_000))
                .with_period(RetrievalFrequency::Annual),
        ),
        "enterprise-values",
        EndpointBounds::new().with_response_rows(1_000),
    );
}

fn assert_facts<Q, R>(endpoint: &EndpointSpec<Q, R>, path: &'static str, bounds: EndpointBounds) {
    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(), bounds);
    assert_eq!(endpoint.metadata().conditional_plan(), None);
    assert_eq!(endpoint.metadata().realtime(), None);
}

#[test]
fn endpoint_queries_expose_exact_values_and_preserve_documented_order() {
    let symbol = Ticker::new("BRK.B / Class A").unwrap();
    let latest_query = LatestFinancialStatementsQuery::new()
        .with_page(Page(0))
        .with_limit(Limit(250));
    let scores_query: FinancialScoresQuery = (&symbol).into();
    let owner_query = OwnerEarningsQuery::new(symbol.clone()).with_limit(Limit(5));
    let enterprise_query = EnterpriseValuesQuery::new(symbol.clone())
        .with_limit(Limit(7))
        .with_period(FiscalPeriod::Q4);

    assert_eq!(latest_query.page(), Some(Page(0)));
    assert_eq!(latest_query.limit(), Some(Limit(250)));
    assert_eq!(scores_query.symbol(), &symbol);
    assert_eq!(owner_query.symbol(), &symbol);
    assert_eq!(owner_query.limit(), Some(Limit(5)));
    assert_eq!(enterprise_query.symbol(), &symbol);
    assert_eq!(enterprise_query.limit(), Some(Limit(7)));
    assert_eq!(enterprise_query.period(), Some(FiscalPeriod::Q4.into()));

    assert_eq!(
        latest_financial_statements(latest_query).query().page(),
        Some(Page(0))
    );
    assert_eq!(financial_scores(scores_query).query().symbol(), &symbol);
    assert_eq!(owner_earnings(owner_query).query().limit(), Some(Limit(5)));
    assert_eq!(
        enterprise_values(enterprise_query).query().period(),
        Some(FiscalPeriod::Q4.into())
    );
}

#[tokio::test]
async fn custom_proxy_auth_headers_and_routing_preserve_all_four_contracts() {
    let executor = Arc::new(FixtureExecutor::new([
        json_fixture(LATEST),
        json_fixture(SCORES),
        json_fixture(OWNER),
        json_fixture(ENTERPRISE),
    ]));
    let client = Client::builder()
        .base_url("https://proxy.example/router")
        .path_prefix("stable")
        .default_header("x-data-scope", "financial-summaries")
        .authentication(Authentication::custom_header(
            "x-router-token",
            Some("Token ".to_owned()),
            "proxy-secret",
        ))
        .executor(executor.clone())
        .build()
        .unwrap();
    let symbol = Ticker::new("BRK.B / Class A").unwrap();

    let latest = client
        .latest_financial_statements(
            LatestFinancialStatementsQuery::new()
                .with_page(Page(0))
                .with_limit(Limit(250)),
        )
        .await
        .unwrap();
    let scores = client.financial_scores(&symbol).await.unwrap();
    let owner = client
        .owner_earnings(OwnerEarningsQuery::new(symbol.clone()).with_limit(Limit(5)))
        .await
        .unwrap();
    let enterprise = client
        .enterprise_values(
            EnterpriseValuesQuery::new(symbol)
                .with_limit(Limit(7))
                .with_period(RetrievalFrequency::Quarterly),
        )
        .await
        .unwrap();

    assert_eq!(latest[0].calendar_year.get(), 2026);
    assert_eq!(scores[0].market_cap, 5_042_169_135_511);
    assert_eq!(owner[0].growth_capex, -2_130_994_500);
    assert_eq!(enterprise[0].enterprise_value, 3_895_186_810_000);

    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"] == "financial-summaries"
    }));
    assert_eq!(
        requests
            .iter()
            .map(|request| request.expose_url().as_str())
            .collect::<Vec<_>>(),
        [
            "https://proxy.example/router/stable/latest-financial-statements?page=0&limit=250",
            "https://proxy.example/router/stable/financial-scores?symbol=BRK.B+%2F+Class+A",
            "https://proxy.example/router/stable/owner-earnings?symbol=BRK.B+%2F+Class+A&limit=5",
            "https://proxy.example/router/stable/enterprise-values?symbol=BRK.B+%2F+Class+A&limit=7&period=quarter",
        ]
    );
}

#[tokio::test]
async fn direct_header_and_query_auth_apply_to_every_compact_endpoint() {
    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(LATEST),
            json_fixture(SCORES),
            json_fixture(OWNER),
            json_fixture(ENTERPRISE),
        ]));
        let client = Client::builder()
            .authentication(authentication)
            .executor(executor.clone())
            .build()
            .unwrap();
        let symbol = Ticker::new("AAPL").unwrap();

        client
            .latest_financial_statements(LatestFinancialStatementsQuery::new().with_page(Page(0)))
            .await
            .unwrap();
        client.financial_scores(&symbol).await.unwrap();
        client.owner_earnings(&symbol).await.unwrap();
        client
            .enterprise_values(
                EnterpriseValuesQuery::new(symbol).with_period(FiscalPeriod::FullYear),
            )
            .await
            .unwrap();

        let requests = executor.requests();
        let expected = [
            format!(
                "https://financialmodelingprep.com/stable/latest-financial-statements?page=0{query_suffix}"
            ),
            format!(
                "https://financialmodelingprep.com/stable/financial-scores?symbol=AAPL{query_suffix}"
            ),
            format!(
                "https://financialmodelingprep.com/stable/owner-earnings?symbol=AAPL{query_suffix}"
            ),
            format!(
                "https://financialmodelingprep.com/stable/enterprise-values?symbol=AAPL&period=FY{query_suffix}"
            ),
        ];
        assert_eq!(
            requests
                .iter()
                .map(|request| request.expose_url().as_str())
                .collect::<Vec<_>>(),
            expected
        );
        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")),
            }
        }
    }
}