libfmp 0.3.0

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

use std::sync::Arc;

use libfmp::{
    Client,
    codecs::TrueFalseFlag,
    config::Authentication,
    endpoints::{
        EndpointSpec,
        directory::{
            CikListQuery, SymbolChangesQuery, actively_trading, cik_list, company_symbols,
            earnings_transcript_list, etf_symbols, financial_statement_symbols, symbol_changes,
        },
        metadata::{AccessRequirement, EndpointBounds, GeographicAvailability},
    },
    transport::HttpMethod,
    types::{Limit, Page},
};

use support::{FixtureExecutor, json_fixture};

const COMPANY: &[u8] = include_bytes!("fixtures/directory_company_symbols.json");
const FINANCIAL: &[u8] = include_bytes!("fixtures/directory_financial_statement_symbols.json");
const CIK: &[u8] = include_bytes!("fixtures/directory_cik_list.json");
const CHANGES: &[u8] = include_bytes!("fixtures/directory_symbol_changes.json");
const ETF: &[u8] = include_bytes!("fixtures/directory_etf_symbols.json");
const ACTIVE: &[u8] = include_bytes!("fixtures/directory_actively_trading.json");
const TRANSCRIPTS: &[u8] = include_bytes!("fixtures/directory_earnings_transcript_list.json");
const EMPTY: &[u8] = include_bytes!("fixtures/directory_empty.json");

#[test]
fn descriptors_use_exact_paths_and_only_documented_geography_and_bounds() {
    let cik_query = CikListQuery::new()
        .with_page(Page(0))
        .with_limit(Limit(1_000));
    let changes_query = SymbolChangesQuery::new()
        .with_invalid(TrueFalseFlag::False)
        .with_limit(Limit(100));

    assert_eq!(cik_query.page(), Some(Page(0)));
    assert_eq!(cik_query.limit(), Some(Limit(1_000)));
    assert_eq!(changes_query.invalid(), Some(TrueFalseFlag::False));
    assert_eq!(changes_query.limit(), Some(Limit(100)));

    let endpoint_facts = [
        facts(&company_symbols()),
        facts(&financial_statement_symbols()),
        facts(&cik_list(cik_query)),
        facts(&symbol_changes(changes_query)),
        facts(&etf_symbols()),
        facts(&actively_trading()),
        facts(&earnings_transcript_list()),
    ];
    assert_eq!(
        endpoint_facts,
        [
            ("stock-list", GeographicAvailability::Worldwide),
            (
                "financial-statement-symbol-list",
                GeographicAvailability::Worldwide
            ),
            ("cik-list", GeographicAvailability::UsOnly),
            ("symbol-change", GeographicAvailability::UsOnly),
            ("etf-list", GeographicAvailability::Worldwide),
            ("actively-trading-list", GeographicAvailability::Worldwide),
            ("earnings-transcript-list", GeographicAvailability::UsOnly),
        ]
    );

    let cik_bounds = cik_list(CikListQuery::new()).metadata().bounds();
    assert_eq!(cik_bounds.limit(), None);
    assert_eq!(cik_bounds.page(), None);
    assert!(cik_bounds.accepts_response_rows(10_000));
    assert!(!cik_bounds.accepts_response_rows(10_001));
    assert!(cik_bounds.accepts_limit(Limit(10_001)));

    for endpoint in [
        company_symbols().metadata(),
        financial_statement_symbols().metadata(),
        symbol_changes(SymbolChangesQuery::new()).metadata(),
        etf_symbols().metadata(),
        actively_trading().metadata(),
        earnings_transcript_list().metadata(),
    ] {
        assert_eq!(endpoint.bounds(), EndpointBounds::new());
    }
}

fn facts<Q, R>(endpoint: &EndpointSpec<Q, R>) -> (&'static str, GeographicAvailability) {
    assert_eq!(endpoint.method(), HttpMethod::Get);
    assert_eq!(endpoint.id(), endpoint.relative_path());
    assert_eq!(endpoint.metadata().access(), AccessRequirement::Unspecified);
    assert_eq!(endpoint.metadata().conditional_plan(), None);
    assert_eq!(endpoint.metadata().realtime(), None);
    (endpoint.id(), endpoint.metadata().geography())
}

#[tokio::test]
async fn proxy_client_executes_all_directory_paths_with_exact_queries_and_no_stray_markers() {
    let executor = Arc::new(FixtureExecutor::new([
        json_fixture(COMPANY),
        json_fixture(FINANCIAL),
        json_fixture(CIK),
        json_fixture(CHANGES),
        json_fixture(ETF),
        json_fixture(ACTIVE),
        json_fixture(TRANSCRIPTS),
        json_fixture(CIK),
        json_fixture(CHANGES),
    ]));
    let client = proxy_client(executor.clone());

    assert_eq!(client.company_symbols().await.unwrap().len(), 1);
    assert_eq!(client.financial_statement_symbols().await.unwrap().len(), 1);
    assert_eq!(
        client.cik_list(CikListQuery::new()).await.unwrap()[0]
            .cik
            .as_str(),
        "0002137358"
    );
    assert_eq!(
        client
            .symbol_changes(SymbolChangesQuery::new())
            .await
            .unwrap()
            .len(),
        1
    );
    assert_eq!(client.etf_symbols().await.unwrap().len(), 1);
    assert_eq!(client.actively_trading().await.unwrap().len(), 1);
    assert_eq!(
        client.earnings_transcript_list().await.unwrap()[0]
            .no_of_transcripts
            .as_str(),
        "6"
    );
    client
        .cik_list(
            CikListQuery::new()
                .with_page(Page(0))
                .with_limit(Limit(10_001)),
        )
        .await
        .unwrap();
    client
        .symbol_changes(
            SymbolChangesQuery::new()
                .with_invalid(TrueFalseFlag::False)
                .with_limit(Limit(100)),
        )
        .await
        .unwrap();

    let requests = executor.requests();
    let urls = requests
        .iter()
        .map(|request| request.expose_url().as_str())
        .collect::<Vec<_>>();
    assert_eq!(
        urls,
        [
            "https://proxy.example/router/stable/stock-list",
            "https://proxy.example/router/stable/financial-statement-symbol-list",
            "https://proxy.example/router/stable/cik-list",
            "https://proxy.example/router/stable/symbol-change",
            "https://proxy.example/router/stable/etf-list",
            "https://proxy.example/router/stable/actively-trading-list",
            "https://proxy.example/router/stable/earnings-transcript-list",
            "https://proxy.example/router/stable/cik-list?page=0&limit=10001",
            "https://proxy.example/router/stable/symbol-change?invalid=false&limit=100",
        ]
    );
    assert!(urls[..7].iter().all(|url| !url.contains('?')));
    assert!(
        requests
            .iter()
            .all(|request| request.method() == HttpMethod::Get)
    );
}

#[tokio::test]
async fn client_preserves_empty_arrays_for_all_seven_directory_endpoints() {
    let executor = Arc::new(FixtureExecutor::new(
        std::iter::repeat_with(|| json_fixture(EMPTY)).take(7),
    ));
    let client = proxy_client(executor);

    assert!(client.company_symbols().await.unwrap().is_empty());
    assert!(
        client
            .financial_statement_symbols()
            .await
            .unwrap()
            .is_empty()
    );
    assert!(
        client
            .cik_list(CikListQuery::new())
            .await
            .unwrap()
            .is_empty()
    );
    assert!(
        client
            .symbol_changes(SymbolChangesQuery::new())
            .await
            .unwrap()
            .is_empty()
    );
    assert!(client.etf_symbols().await.unwrap().is_empty());
    assert!(client.actively_trading().await.unwrap().is_empty());
    assert!(client.earnings_transcript_list().await.unwrap().is_empty());
}

#[tokio::test]
async fn direct_fmp_auth_uses_the_same_company_symbols_contract() {
    for (authentication, expected_url, expected_header) in [
        (
            Authentication::fmp_header("header-secret"),
            "https://financialmodelingprep.com/stable/stock-list",
            Some(("apikey", "header-secret")),
        ),
        (
            Authentication::fmp_query("query-secret"),
            "https://financialmodelingprep.com/stable/stock-list?apikey=query-secret",
            None,
        ),
    ] {
        let executor = Arc::new(FixtureExecutor::new([json_fixture(COMPANY)]));
        let client = Client::builder()
            .authentication(authentication)
            .executor(executor.clone())
            .build()
            .unwrap();

        client.company_symbols().await.unwrap();

        let requests = executor.requests();
        assert_eq!(requests[0].expose_url().as_str(), expected_url);
        match expected_header {
            Some((name, value)) => assert_eq!(requests[0].expose_headers()[name], value),
            None => assert!(!requests[0].expose_headers().contains_key("apikey")),
        }
    }
}

fn proxy_client(executor: Arc<FixtureExecutor>) -> Client {
    Client::builder()
        .base_url("https://proxy.example/router")
        .path_prefix("stable")
        .authentication(Authentication::None)
        .executor(executor)
        .build()
        .unwrap()
}