libfmp 0.3.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},
        transcripts::{
            EarningsTranscriptDatesQuery, EarningsTranscriptQuery, LatestEarningsTranscriptsQuery,
            earnings_transcript, earnings_transcript_dates, earnings_transcript_list,
            latest_earnings_transcripts,
        },
    },
    query::{FiscalPeriod, Quarter, Year},
    responses::transcripts::EarningsTranscript,
    transport::HttpMethod,
    types::{CalendarYear, Limit, Page, Ticker},
};

use support::{FixtureExecutor, json_fixture};

const LATEST: &[u8] = include_bytes!("fixtures/latest_earnings_transcripts.json");
const TRANSCRIPT: &[u8] = include_bytes!("fixtures/earnings_transcript.json");
const DATES: &[u8] = include_bytes!("fixtures/earnings_transcript_dates.json");
const AVAILABILITY: &[u8] = include_bytes!("fixtures/directory_earnings_transcript_list.json");

#[test]
fn descriptor_uses_exact_path_query_row_and_worldwide_metadata_without_bounds() {
    let query = EarningsTranscriptQuery::new(Ticker::new("AAPL").unwrap(), Year(2020), Quarter::Q3)
        .with_limit(Limit(u32::MAX));
    let endpoint: EndpointSpec<EarningsTranscriptQuery, Vec<EarningsTranscript>> =
        earnings_transcript(query);

    assert_eq!(endpoint.method(), HttpMethod::Get);
    assert_eq!(endpoint.id(), "earning-call-transcript");
    assert_eq!(endpoint.relative_path(), "earning-call-transcript");
    assert_eq!(endpoint.query().symbol().as_str(), "AAPL");
    assert_eq!(endpoint.query().year(), Year(2020));
    assert_eq!(endpoint.query().quarter(), Quarter::Q3);
    assert_eq!(endpoint.query().limit(), Some(Limit(u32::MAX)));
    assert_eq!(
        endpoint.metadata().geography(),
        GeographicAvailability::Worldwide
    );
    assert_eq!(endpoint.metadata().access(), AccessRequirement::Unspecified);
    assert_eq!(endpoint.metadata().bounds(), EndpointBounds::new());
    assert!(endpoint.metadata().bounds().accepts_limit(Limit(u32::MAX)));
    assert_eq!(endpoint.metadata().conditional_plan(), None);
    assert_eq!(endpoint.metadata().realtime(), None);
}

#[tokio::test]
async fn proxy_integration_covers_all_four_transcript_paths_without_duplication() {
    let executor = Arc::new(FixtureExecutor::new([
        json_fixture(LATEST),
        json_fixture(TRANSCRIPT),
        json_fixture(DATES),
        json_fixture(AVAILABILITY),
    ]));
    let client = Client::builder()
        .base_url("https://proxy.example/router")
        .path_prefix("/gateway/stable/")
        .authentication(Authentication::custom_header(
            "x-router-token",
            Some("Token ".to_owned()),
            "proxy-secret",
        ))
        .default_header("x-data-scope", "transcripts")
        .executor(executor.clone())
        .build()
        .unwrap();
    let symbol = Ticker::new("AAPL").unwrap();

    let latest = client
        .latest_earnings_transcripts(
            LatestEarningsTranscriptsQuery::new()
                .with_limit(Limit(100))
                .with_page(Page(0)),
        )
        .await
        .unwrap();
    let transcripts = client
        .earnings_transcript(
            EarningsTranscriptQuery::new(symbol.clone(), Year(2020), Quarter::Q3)
                .with_limit(Limit(1)),
        )
        .await
        .unwrap();
    let dates = client.earnings_transcript_dates(symbol).await.unwrap();
    let availability = client.earnings_transcript_list().await.unwrap();

    assert_eq!(latest[0].fiscal_year, CalendarYear(2026));
    assert_eq!(transcripts.len(), 1);
    assert_eq!(transcripts[0].period, FiscalPeriod::Q3);
    assert_eq!(transcripts[0].year, CalendarYear(2020));
    assert!(transcripts[0].content.contains("\nTejas Gala:"));
    assert!(transcripts[0].content.ends_with("Aft..."));
    assert_eq!(dates[0].fiscal_year, CalendarYear(2026));
    assert_eq!(availability[0].no_of_transcripts.as_str(), "6");

    let transcript_json: serde_json::Value = serde_json::from_slice(TRANSCRIPT).unwrap();
    assert!(transcript_json[0]["year"].is_u64());
    assert!(transcript_json[0].get("fiscalYear").is_none());
    assert_eq!(
        transcripts[0].content,
        transcript_json[0]["content"].as_str().unwrap()
    );

    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"] == "transcripts"
    }));
    assert_eq!(
        requests
            .iter()
            .map(|request| request.expose_url().as_str())
            .collect::<Vec<_>>(),
        [
            "https://proxy.example/router/gateway/stable/earning-call-transcript-latest?limit=100&page=0",
            "https://proxy.example/router/gateway/stable/earning-call-transcript?symbol=AAPL&year=2020&quarter=3&limit=1",
            "https://proxy.example/router/gateway/stable/earning-call-transcript-dates?symbol=AAPL",
            "https://proxy.example/router/gateway/stable/earnings-transcript-list",
        ]
    );

    let list = earnings_transcript_list();
    assert_eq!(list.metadata().geography(), GeographicAvailability::UsOnly);
    assert_eq!(list.id(), "earnings-transcript-list");
    assert_eq!(
        latest_earnings_transcripts(LatestEarningsTranscriptsQuery::new()).id(),
        "earning-call-transcript-latest"
    );
    assert_eq!(
        earnings_transcript_dates(EarningsTranscriptDatesQuery::new(
            Ticker::new("AAPL").unwrap()
        ))
        .id(),
        "earning-call-transcript-dates"
    );
}

#[tokio::test]
async fn direct_header_and_query_auth_preserve_required_order_and_optional_limit() {
    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(TRANSCRIPT),
            json_fixture(TRANSCRIPT),
        ]));
        let client = Client::builder()
            .authentication(authentication)
            .executor(executor.clone())
            .build()
            .unwrap();

        client
            .earnings_transcript(EarningsTranscriptQuery::new(
                Ticker::new("AAPL").unwrap(),
                Year(2020),
                Quarter::Q3,
            ))
            .await
            .unwrap();
        client
            .earnings_transcript(
                EarningsTranscriptQuery::new(Ticker::new("AAPL").unwrap(), Year(2020), Quarter::Q3)
                    .with_limit(Limit(u32::MAX)),
            )
            .await
            .unwrap();

        let requests = executor.requests();
        assert_eq!(
            requests
                .iter()
                .map(|request| request.expose_url().as_str())
                .collect::<Vec<_>>(),
            [
                format!(
                    "https://financialmodelingprep.com/stable/earning-call-transcript?symbol=AAPL&year=2020&quarter=3{query_suffix}"
                ),
                format!(
                    "https://financialmodelingprep.com/stable/earning-call-transcript?symbol=AAPL&year=2020&quarter=3&limit=4294967295{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")),
            }
        }
    }
}