Skip to main content

rusty_fmp/
client.rs

1//! HTTP client for Financial Modeling Prep stable endpoints.
2//!
3//! The client exposes a small set of *shape*-based methods. Each method
4//! corresponds to a common FMP query shape (no parameters, symbol only, date
5//! range, annual statement, etc.) and accepts an [`Endpoint`] descriptor from
6//! [`crate::endpoint`].
7//!
8//! # Errors
9//!
10//! All request methods return [`Error`] when:
11//!
12//! - the HTTP request fails before producing a response,
13//! - the API returns a non-2xx status, or
14//! - the response body is not valid JSON.
15//!
16//! # Logging
17//!
18//! The client emits [`log`] crate traces at `DEBUG` level for each HTTP
19//! request (URL with API key redacted) and response status, and at `WARN`
20//! level for non-2xx API responses. Enable a log subscriber such as
21//! `env_logger` to surface these traces.
22
23use std::fmt;
24
25use reqwest::{StatusCode, Url};
26use serde_json::Value;
27
28use crate::endpoint::{ANNUAL_LIMIT, ANNUAL_PERIOD, Endpoint, NEWS_LIMIT, PAGE};
29use crate::error::{Error, Result};
30
31const DEFAULT_BASE_URL: &str = "https://financialmodelingprep.com/stable/";
32
33/// Async client for Financial Modeling Prep stable API endpoints.
34#[derive(Clone)]
35pub struct FmpClient {
36    http: reqwest::Client,
37    base_url: Url,
38    api_key: String,
39}
40
41impl fmt::Debug for FmpClient {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        formatter
44            .debug_struct("FmpClient")
45            .field("http", &self.http)
46            .field("base_url", &self.base_url)
47            .field("api_key", &"<redacted>")
48            .finish()
49    }
50}
51
52impl FmpClient {
53    /// Builds a client using the default stable FMP base URL.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::InvalidBaseUrl`] only if the compiled-in default URL is invalid.
58    pub fn new(api_key: impl Into<String>) -> Result<Self> {
59        Self::with_base_url(api_key, DEFAULT_BASE_URL)
60    }
61
62    /// Builds a client using a custom base URL, primarily for tests.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::InvalidBaseUrl`] if `base_url` cannot be parsed, uses
67    /// an unsupported scheme, contains credentials, or contains query or
68    /// fragment components.
69    pub fn with_base_url(api_key: impl Into<String>, base_url: impl AsRef<str>) -> Result<Self> {
70        let base_url = normalize_base_url(base_url.as_ref())?;
71
72        Ok(Self {
73            http: reqwest::Client::new(),
74            base_url,
75            api_key: api_key.into(),
76        })
77    }
78
79    /// Calls `endpoint` without endpoint-specific query parameters.
80    ///
81    /// # Errors
82    ///
83    /// See [module-level errors](self#errors).
84    pub async fn endpoint(&self, endpoint: Endpoint) -> Result<Value> {
85        self.get(endpoint, &[]).await
86    }
87
88    /// Calls `endpoint` with a free-text `query` parameter (`?query=...`).
89    ///
90    /// # Errors
91    ///
92    /// See [module-level errors](self#errors).
93    pub async fn query(&self, endpoint: Endpoint, query: &str) -> Result<Value> {
94        self.get(endpoint, &[("query", query)]).await
95    }
96
97    /// Calls `endpoint` with a `?symbol=...` parameter.
98    ///
99    /// # Errors
100    ///
101    /// See [module-level errors](self#errors).
102    pub async fn by_symbol(&self, endpoint: Endpoint, symbol: &str) -> Result<Value> {
103        self.get(endpoint, &[("symbol", symbol)]).await
104    }
105
106    /// Calls `endpoint` with a `?symbol=...` parameter (comma-joined).
107    ///
108    /// # Errors
109    ///
110    /// See [module-level errors](self#errors).
111    pub async fn by_symbol_list(&self, endpoint: Endpoint, symbols: &[String]) -> Result<Value> {
112        let joined = symbols.join(",");
113        self.get(endpoint, &[("symbol", &joined)]).await
114    }
115
116    /// Calls `endpoint` with a `?symbols=...` parameter (comma-joined).
117    ///
118    /// # Errors
119    ///
120    /// See [module-level errors](self#errors).
121    pub async fn by_symbols(&self, endpoint: Endpoint, symbols: &[String]) -> Result<Value> {
122        let joined = symbols.join(",");
123        self.get(endpoint, &[("symbols", &joined)]).await
124    }
125
126    /// Calls `endpoint` with `?symbol=...&limit=...` (limit defaults to
127    /// [`NEWS_LIMIT`]).
128    ///
129    /// # Errors
130    ///
131    /// See [module-level errors](self#errors).
132    pub async fn by_symbol_limit(
133        &self,
134        endpoint: Endpoint,
135        symbol: &str,
136        limit: Option<u16>,
137    ) -> Result<Value> {
138        let limit = limit.unwrap_or(NEWS_LIMIT).to_string();
139        self.get(endpoint, &[("symbol", symbol), ("limit", &limit)])
140            .await
141    }
142
143    /// Calls `endpoint` with `?symbol=...` plus optional `from` and `to` dates.
144    ///
145    /// # Errors
146    ///
147    /// See [module-level errors](self#errors).
148    pub async fn by_symbol_date_range(
149        &self,
150        endpoint: Endpoint,
151        symbol: &str,
152        from: Option<&str>,
153        to: Option<&str>,
154    ) -> Result<Value> {
155        let mut params = vec![("symbol", symbol)];
156        push_opt(&mut params, "from", from);
157        push_opt(&mut params, "to", to);
158        self.get(endpoint, &params).await
159    }
160
161    /// Calls `endpoint` with optional `from` and `to` dates.
162    ///
163    /// # Errors
164    ///
165    /// See [module-level errors](self#errors).
166    pub async fn by_date_range(
167        &self,
168        endpoint: Endpoint,
169        from: Option<&str>,
170        to: Option<&str>,
171    ) -> Result<Value> {
172        let mut params = Vec::new();
173        push_opt(&mut params, "from", from);
174        push_opt(&mut params, "to", to);
175        self.get(endpoint, &params).await
176    }
177
178    /// Calls `endpoint` with a required `name` plus optional `from` and `to` dates.
179    ///
180    /// # Errors
181    ///
182    /// See [module-level errors](self#errors).
183    pub async fn by_name_date_range(
184        &self,
185        endpoint: Endpoint,
186        name: &str,
187        from: Option<&str>,
188        to: Option<&str>,
189    ) -> Result<Value> {
190        let mut params = vec![("name", name)];
191        push_opt(&mut params, "from", from);
192        push_opt(&mut params, "to", to);
193        self.get(endpoint, &params).await
194    }
195
196    /// Calls an annual `endpoint` with `symbol` and `limit` (defaulting to
197    /// [`ANNUAL_LIMIT`]).
198    ///
199    /// # Errors
200    ///
201    /// See [module-level errors](self#errors).
202    pub async fn annual(
203        &self,
204        endpoint: Endpoint,
205        symbol: &str,
206        limit: Option<u16>,
207    ) -> Result<Value> {
208        self.annual_with_period(endpoint, symbol, None, limit).await
209    }
210
211    /// Calls a statement-style `endpoint` with `symbol`, `period` (defaulting
212    /// to [`ANNUAL_PERIOD`]), and `limit` (defaulting to [`ANNUAL_LIMIT`]).
213    pub(crate) async fn annual_with_period(
214        &self,
215        endpoint: Endpoint,
216        symbol: &str,
217        period: Option<&str>,
218        limit: Option<u16>,
219    ) -> Result<Value> {
220        let period = period.unwrap_or(ANNUAL_PERIOD);
221        let limit = limit.unwrap_or(ANNUAL_LIMIT).to_string();
222        self.get(
223            endpoint,
224            &[("symbol", symbol), ("period", period), ("limit", &limit)],
225        )
226        .await
227    }
228
229    /// Calls an annual report form `endpoint` with `symbol`, `year`, and fiscal `period`.
230    ///
231    /// # Errors
232    ///
233    /// See [module-level errors](self#errors).
234    pub async fn annual_report_form(
235        &self,
236        endpoint: Endpoint,
237        symbol: &str,
238        year: u16,
239        period: &str,
240    ) -> Result<Value> {
241        let year = year.to_string();
242        self.get(
243            endpoint,
244            &[("symbol", symbol), ("year", &year), ("period", period)],
245        )
246        .await
247    }
248
249    /// Calls a technical indicator `endpoint` with `symbol`, `periodLength`,
250    /// and `timeframe`.
251    ///
252    /// # Errors
253    ///
254    /// See [module-level errors](self#errors).
255    pub async fn technical(
256        &self,
257        endpoint: Endpoint,
258        symbol: &str,
259        period_length: u16,
260        timeframe: &str,
261    ) -> Result<Value> {
262        let period_length = period_length.to_string();
263        self.get(
264            endpoint,
265            &[
266                ("symbol", symbol),
267                ("periodLength", &period_length),
268                ("timeframe", timeframe),
269            ],
270        )
271        .await
272    }
273
274    /// Calls a news `endpoint` with `?symbols=...&limit=...` (limit defaults to
275    /// [`NEWS_LIMIT`]).
276    ///
277    /// # Errors
278    ///
279    /// See [module-level errors](self#errors).
280    pub async fn news(
281        &self,
282        endpoint: Endpoint,
283        symbol: &str,
284        limit: Option<u16>,
285    ) -> Result<Value> {
286        let limit = limit.unwrap_or(NEWS_LIMIT).to_string();
287        self.get(endpoint, &[("symbols", symbol), ("limit", &limit)])
288            .await
289    }
290
291    /// Calls a paginated `endpoint` with `?page=...&limit=...` (`page`
292    /// defaults to [`PAGE`] and `limit` defaults to [`NEWS_LIMIT`]).
293    ///
294    /// # Errors
295    ///
296    /// See [module-level errors](self#errors).
297    pub async fn paged(
298        &self,
299        endpoint: Endpoint,
300        page: Option<u16>,
301        limit: Option<u16>,
302    ) -> Result<Value> {
303        let page = page.unwrap_or(PAGE).to_string();
304        let limit = limit.unwrap_or(NEWS_LIMIT).to_string();
305        self.get(endpoint, &[("page", &page), ("limit", &limit)])
306            .await
307    }
308
309    async fn get(&self, endpoint: Endpoint, params: &[(&str, &str)]) -> Result<Value> {
310        let url = self.build_url(endpoint.path(), params)?;
311        log::debug!("GET {}", self.redact_url(&url));
312        let response = self.http.get(url).send().await?;
313        let status = response.status();
314        log::debug!("response status: {status}");
315        let body = response.text().await?;
316
317        if !status.is_success() {
318            let summary = summarize_body(status, &body, &self.api_key);
319            if status == StatusCode::TOO_MANY_REQUESTS {
320                log::warn!("rate limited {}: {}", status, summary);
321                return Err(Error::RateLimited {
322                    status: status.as_u16(),
323                    message: summary,
324                });
325            }
326
327            log::warn!("API error {}: {}", status, summary);
328            return Err(Error::Api {
329                status: status.as_u16(),
330                message: summary,
331            });
332        }
333
334        Ok(serde_json::from_str(&body)?)
335    }
336
337    fn build_url(&self, path: &str, params: &[(&str, &str)]) -> Result<Url> {
338        let mut url = self
339            .base_url
340            .join(path)
341            .map_err(|_| Error::InvalidBaseUrl(self.base_url.to_string()))?;
342        {
343            let mut query = url.query_pairs_mut();
344            for (key, value) in params {
345                query.append_pair(key, value);
346            }
347            query.append_pair("apikey", &self.api_key);
348        }
349        Ok(url)
350    }
351
352    fn redact_url(&self, url: &Url) -> Url {
353        let mut display = url.clone();
354        let pairs: Vec<(String, String)> = url
355            .query_pairs()
356            .map(|(k, v)| {
357                if k == "apikey" {
358                    (k.into_owned(), "***".to_owned())
359                } else {
360                    (k.into_owned(), v.into_owned())
361                }
362            })
363            .collect();
364        display.set_query(None);
365        {
366            let mut query = display.query_pairs_mut();
367            for (k, v) in &pairs {
368                query.append_pair(k, v);
369            }
370        }
371        display
372    }
373}
374
375fn push_opt<'a>(params: &mut Vec<(&'a str, &'a str)>, key: &'a str, value: Option<&'a str>) {
376    if let Some(value) = value {
377        params.push((key, value));
378    }
379}
380
381fn normalize_base_url(base_url: &str) -> Result<Url> {
382    let mut url =
383        Url::parse(base_url).map_err(|_| Error::InvalidBaseUrl("<invalid URL>".to_owned()))?;
384
385    if !matches!(url.scheme(), "http" | "https")
386        || !url.username().is_empty()
387        || url.password().is_some()
388    {
389        return Err(Error::InvalidBaseUrl(sanitize_base_url(&url)));
390    }
391
392    if url.query().is_some() || url.fragment().is_some() {
393        return Err(Error::InvalidBaseUrl(sanitize_base_url(&url)));
394    }
395
396    if !url.path().ends_with('/') {
397        let mut path = url.path().to_owned();
398        path.push('/');
399        url.set_path(&path);
400    }
401
402    Ok(url)
403}
404
405fn sanitize_base_url(url: &Url) -> String {
406    let mut sanitized = url.clone();
407    let _ = sanitized.set_username("");
408    let _ = sanitized.set_password(None);
409    sanitized.set_query(None);
410    sanitized.set_fragment(None);
411    sanitized.to_string()
412}
413
414fn summarize_body(status: StatusCode, body: &str, api_key: &str) -> String {
415    let trimmed = body.trim();
416    if trimmed.is_empty() {
417        return status
418            .canonical_reason()
419            .unwrap_or("empty response")
420            .to_owned();
421    }
422
423    let redacted = if api_key.is_empty() {
424        trimmed.to_owned()
425    } else {
426        trimmed.replace(api_key, "***")
427    };
428    redacted.chars().take(240).collect()
429}
430
431#[cfg(test)]
432mod tests {
433    use httpmock::Method::GET;
434    use httpmock::MockServer;
435    use serde_json::json;
436
437    use super::*;
438    use crate::endpoint::{
439        ANALYST_ESTIMATES, BALANCE_SHEET_STATEMENT, BALANCE_SHEET_STATEMENT_GROWTH, BATCH_QUOTE,
440        CASH_FLOW_STATEMENT, CASH_FLOW_STATEMENT_GROWTH, CRYPTO_NEWS, CRYPTOCURRENCY_LIST,
441        DIVIDENDS, EARNINGS_CALENDAR, ECONOMIC_INDICATORS, ENTERPRISE_VALUES, ETF_HOLDINGS,
442        FINANCIAL_REPORTS_DATES, FINANCIAL_REPORTS_JSON, FINANCIAL_SCORES, FMP_ARTICLES,
443        FOREX_NEWS, GENERAL_NEWS, GRADES, GRADES_CONSENSUS, HISTORICAL_PRICE_EOD_FULL,
444        INCOME_STATEMENT, INCOME_STATEMENT_AS_REPORTED, INCOME_STATEMENT_GROWTH,
445        INSIDER_TRADING_LATEST, KEY_EXECUTIVES, KEY_METRICS, PRICE_TARGET_CONSENSUS,
446        PRICE_TARGET_SUMMARY, PROFILE, QUOTE, RATINGS_HISTORICAL, RATIOS, SEARCH_SYMBOL,
447        SEC_FILINGS_SEARCH_SYMBOL, SHARES_FLOAT, SPLITS, STOCK_LIST, STOCK_PEERS,
448        STOCK_PRICE_CHANGE, TECHNICAL_SMA, TREASURY_RATES,
449    };
450
451    /// Returns a client wired to `server`'s base URL with `api_key=test-key`.
452    fn test_client(server: &MockServer) -> FmpClient {
453        FmpClient::with_base_url("test-key", format!("{}/", server.base_url())).unwrap()
454    }
455
456    #[test]
457    fn debug_output_redacts_api_key() {
458        let client = FmpClient::with_base_url("secret-key", DEFAULT_BASE_URL).unwrap();
459
460        let output = format!("{client:?}");
461
462        assert!(output.contains("<redacted>"));
463        assert!(!output.contains("secret-key"));
464    }
465
466    #[test]
467    fn with_base_url_adds_trailing_slash() {
468        let client = FmpClient::with_base_url("test-key", "https://example.com/stable").unwrap();
469        let url = client.build_url("quote", &[]).unwrap();
470
471        assert_eq!(
472            url.as_str(),
473            "https://example.com/stable/quote?apikey=test-key"
474        );
475    }
476
477    #[test]
478    fn with_base_url_preserves_existing_trailing_slash() {
479        let client = FmpClient::with_base_url("test-key", "https://example.com/stable/").unwrap();
480        let url = client.build_url("quote", &[]).unwrap();
481
482        assert_eq!(
483            url.as_str(),
484            "https://example.com/stable/quote?apikey=test-key"
485        );
486    }
487
488    #[test]
489    fn with_base_url_rejects_query_and_fragment() {
490        let query_error =
491            FmpClient::with_base_url("test-key", "https://example.com/stable?token=secret-token")
492                .unwrap_err()
493                .to_string();
494        assert!(query_error.contains("https://example.com/stable"));
495        assert!(!query_error.contains("secret-token"));
496
497        let fragment_error =
498            FmpClient::with_base_url("test-key", "https://example.com/stable#secret-fragment")
499                .unwrap_err()
500                .to_string();
501        assert!(fragment_error.contains("https://example.com/stable"));
502        assert!(!fragment_error.contains("secret-fragment"));
503    }
504
505    #[test]
506    fn with_base_url_rejects_credentials_and_unsupported_schemes() {
507        let credentials_error = FmpClient::with_base_url(
508            "test-key",
509            "https://user:secret-password@example.com/stable",
510        )
511        .unwrap_err()
512        .to_string();
513        assert!(credentials_error.contains("https://example.com/stable"));
514        assert!(!credentials_error.contains("secret-password"));
515
516        assert!(matches!(
517            FmpClient::with_base_url("test-key", "ftp://example.com/stable"),
518            Err(Error::InvalidBaseUrl(_))
519        ));
520    }
521
522    #[tokio::test]
523    async fn endpoint_sends_expected_request() {
524        let server = MockServer::start_async().await;
525        let endpoints = [CRYPTOCURRENCY_LIST, STOCK_LIST];
526        let mut mocks = Vec::new();
527
528        for endpoint in endpoints {
529            mocks.push(
530                server
531                    .mock_async(|when, then| {
532                        when.method(GET)
533                            .path(format!("/{}", endpoint.path()))
534                            .query_param("apikey", "test-key");
535                        then.status(200).json_body(json!([{ "ok": true }]));
536                    })
537                    .await,
538            );
539        }
540
541        let client = test_client(&server);
542        for endpoint in endpoints {
543            let value = client.endpoint(endpoint).await.unwrap();
544            assert_eq!(value[0]["ok"], true);
545        }
546
547        for mock in mocks {
548            mock.assert_async().await;
549        }
550    }
551
552    #[tokio::test]
553    async fn query_sends_expected_request() {
554        let server = MockServer::start_async().await;
555        let mock = server
556            .mock_async(|when, then| {
557                when.method(GET)
558                    .path("/search-symbol")
559                    .query_param("query", "AAPL")
560                    .query_param("apikey", "test-key");
561                then.status(200)
562                    .json_body(json!([{ "symbol": "AAPL", "name": "Apple Inc." }]));
563            })
564            .await;
565
566        let value = test_client(&server)
567            .query(SEARCH_SYMBOL, "AAPL")
568            .await
569            .unwrap();
570
571        mock.assert_async().await;
572        assert_eq!(value[0]["symbol"], "AAPL");
573    }
574
575    #[tokio::test]
576    async fn by_symbols_joins_with_comma() {
577        let server = MockServer::start_async().await;
578        let mock = server
579            .mock_async(|when, then| {
580                when.method(GET)
581                    .path("/batch-quote")
582                    .query_param("symbols", "AAPL,MSFT,GOOGL")
583                    .query_param("apikey", "test-key");
584                then.status(200)
585                    .json_body(json!([{ "symbol": "AAPL" }, { "symbol": "MSFT" }]));
586            })
587            .await;
588
589        let symbols = vec!["AAPL".to_owned(), "MSFT".to_owned(), "GOOGL".to_owned()];
590        let value = test_client(&server)
591            .by_symbols(BATCH_QUOTE, &symbols)
592            .await
593            .unwrap();
594
595        mock.assert_async().await;
596        assert_eq!(value[0]["symbol"], "AAPL");
597        assert_eq!(value[1]["symbol"], "MSFT");
598    }
599
600    #[tokio::test]
601    async fn by_symbol_list_joins_with_comma() {
602        let server = MockServer::start_async().await;
603        let mock = server
604            .mock_async(|when, then| {
605                when.method(GET)
606                    .path("/stock-price-change")
607                    .query_param("symbol", "ALAB,CLS")
608                    .query_param("apikey", "test-key");
609                then.status(200)
610                    .json_body(json!([{ "symbol": "ALAB" }, { "symbol": "CLS" }]));
611            })
612            .await;
613
614        let symbols = vec!["ALAB".to_owned(), "CLS".to_owned()];
615        let value = test_client(&server)
616            .by_symbol_list(STOCK_PRICE_CHANGE, &symbols)
617            .await
618            .unwrap();
619
620        mock.assert_async().await;
621        assert_eq!(value[0]["symbol"], "ALAB");
622        assert_eq!(value[1]["symbol"], "CLS");
623    }
624
625    #[tokio::test]
626    async fn by_symbol_sends_expected_requests() {
627        let server = MockServer::start_async().await;
628        let endpoints = [
629            PROFILE,
630            KEY_EXECUTIVES,
631            QUOTE,
632            STOCK_PEERS,
633            ETF_HOLDINGS,
634            DIVIDENDS,
635            SPLITS,
636            FINANCIAL_SCORES,
637            SHARES_FLOAT,
638            GRADES_CONSENSUS,
639            FINANCIAL_REPORTS_DATES,
640            GRADES,
641            PRICE_TARGET_CONSENSUS,
642            PRICE_TARGET_SUMMARY,
643        ];
644        let mut mocks = Vec::new();
645
646        for endpoint in endpoints {
647            mocks.push(
648                server
649                    .mock_async(|when, then| {
650                        when.method(GET)
651                            .path(format!("/{}", endpoint.path()))
652                            .query_param("symbol", "AAPL")
653                            .query_param("apikey", "test-key");
654                        then.status(200).json_body(json!([{ "symbol": "AAPL" }]));
655                    })
656                    .await,
657            );
658        }
659
660        let client = test_client(&server);
661        for endpoint in endpoints {
662            client.by_symbol(endpoint, "AAPL").await.unwrap();
663        }
664
665        for mock in mocks {
666            mock.assert_async().await;
667        }
668    }
669
670    #[tokio::test]
671    async fn by_symbol_limit_sends_expected_request() {
672        let server = MockServer::start_async().await;
673        let mock = server
674            .mock_async(|when, then| {
675                when.method(GET)
676                    .path("/ratings-historical")
677                    .query_param("symbol", "AAPL")
678                    .query_param("limit", "10")
679                    .query_param("apikey", "test-key");
680                then.status(200).json_body(json!([{ "symbol": "AAPL" }]));
681            })
682            .await;
683
684        let value = test_client(&server)
685            .by_symbol_limit(RATINGS_HISTORICAL, "AAPL", None)
686            .await
687            .unwrap();
688
689        mock.assert_async().await;
690        assert_eq!(value[0]["symbol"], "AAPL");
691    }
692
693    #[tokio::test]
694    async fn by_date_range_sends_expected_requests() {
695        let server = MockServer::start_async().await;
696        let endpoints = [EARNINGS_CALENDAR, TREASURY_RATES];
697        let mut mocks = Vec::new();
698
699        for endpoint in endpoints {
700            mocks.push(
701                server
702                    .mock_async(|when, then| {
703                        when.method(GET)
704                            .path(format!("/{}", endpoint.path()))
705                            .query_param("from", "2025-01-01")
706                            .query_param("to", "2025-01-31")
707                            .query_param("apikey", "test-key");
708                        then.status(200)
709                            .json_body(json!([{ "date": "2025-01-01" }]));
710                    })
711                    .await,
712            );
713        }
714
715        let client = test_client(&server);
716        for endpoint in endpoints {
717            client
718                .by_date_range(endpoint, Some("2025-01-01"), Some("2025-01-31"))
719                .await
720                .unwrap();
721        }
722
723        for mock in mocks {
724            mock.assert_async().await;
725        }
726    }
727
728    #[tokio::test]
729    async fn by_name_date_range_sends_expected_request() {
730        let server = MockServer::start_async().await;
731        let mock = server
732            .mock_async(|when, then| {
733                when.method(GET)
734                    .path("/economic-indicators")
735                    .query_param("name", "GDP")
736                    .query_param("from", "2025-01-01")
737                    .query_param("to", "2025-12-31")
738                    .query_param("apikey", "test-key");
739                then.status(200).json_body(json!([{ "name": "GDP" }]));
740            })
741            .await;
742
743        let value = test_client(&server)
744            .by_name_date_range(
745                ECONOMIC_INDICATORS,
746                "GDP",
747                Some("2025-01-01"),
748                Some("2025-12-31"),
749            )
750            .await
751            .unwrap();
752
753        mock.assert_async().await;
754        assert_eq!(value[0]["name"], "GDP");
755    }
756
757    #[tokio::test]
758    async fn technical_sends_expected_request() {
759        let server = MockServer::start_async().await;
760        let mock = server
761            .mock_async(|when, then| {
762                when.method(GET)
763                    .path("/technical-indicators/sma")
764                    .query_param("symbol", "AAPL")
765                    .query_param("periodLength", "10")
766                    .query_param("timeframe", "1day")
767                    .query_param("apikey", "test-key");
768                then.status(200)
769                    .json_body(json!([{ "symbol": "AAPL", "sma": 200.0 }]));
770            })
771            .await;
772
773        let value = test_client(&server)
774            .technical(TECHNICAL_SMA, "AAPL", 10, "1day")
775            .await
776            .unwrap();
777
778        mock.assert_async().await;
779        assert_eq!(value[0]["symbol"], "AAPL");
780    }
781
782    #[tokio::test]
783    async fn by_symbol_date_range_sends_expected_requests() {
784        let server = MockServer::start_async().await;
785        let endpoints = [HISTORICAL_PRICE_EOD_FULL, SEC_FILINGS_SEARCH_SYMBOL];
786        let mut mocks = Vec::new();
787
788        for endpoint in endpoints {
789            mocks.push(
790                server
791                    .mock_async(|when, then| {
792                        when.method(GET)
793                            .path(format!("/{}", endpoint.path()))
794                            .query_param("symbol", "AAPL")
795                            .query_param("from", "2025-01-01")
796                            .query_param("to", "2025-01-31")
797                            .query_param("apikey", "test-key");
798                        then.status(200).json_body(json!([{ "symbol": "AAPL" }]));
799                    })
800                    .await,
801            );
802        }
803
804        let client = test_client(&server);
805        for endpoint in endpoints {
806            client
807                .by_symbol_date_range(endpoint, "AAPL", Some("2025-01-01"), Some("2025-01-31"))
808                .await
809                .unwrap();
810        }
811
812        for mock in mocks {
813            mock.assert_async().await;
814        }
815    }
816
817    #[tokio::test]
818    async fn annual_endpoints_send_expected_requests() {
819        let server = MockServer::start_async().await;
820        let endpoints = [
821            INCOME_STATEMENT,
822            INCOME_STATEMENT_AS_REPORTED,
823            BALANCE_SHEET_STATEMENT,
824            CASH_FLOW_STATEMENT,
825            RATIOS,
826            KEY_METRICS,
827            INCOME_STATEMENT_GROWTH,
828            BALANCE_SHEET_STATEMENT_GROWTH,
829            CASH_FLOW_STATEMENT_GROWTH,
830            ENTERPRISE_VALUES,
831            ANALYST_ESTIMATES,
832        ];
833        let mut mocks = Vec::new();
834
835        for endpoint in endpoints {
836            mocks.push(
837                server
838                    .mock_async(|when, then| {
839                        when.method(GET)
840                            .path(format!("/{}", endpoint.path()))
841                            .query_param("symbol", "AAPL")
842                            .query_param("period", "annual")
843                            .query_param("limit", "5")
844                            .query_param("apikey", "test-key");
845                        then.status(200).json_body(json!([{ "symbol": "AAPL" }]));
846                    })
847                    .await,
848            );
849        }
850
851        let client = test_client(&server);
852        for endpoint in endpoints {
853            client.annual(endpoint, "AAPL", None).await.unwrap();
854        }
855
856        for mock in mocks {
857            mock.assert_async().await;
858        }
859    }
860
861    #[tokio::test]
862    async fn annual_endpoint_sends_custom_period_and_limit() {
863        let server = MockServer::start_async().await;
864        let mock = server
865            .mock_async(|when, then| {
866                when.method(GET)
867                    .path("/income-statement")
868                    .query_param("symbol", "AAPL")
869                    .query_param("period", "quarter")
870                    .query_param("limit", "4")
871                    .query_param("apikey", "test-key");
872                then.status(200).json_body(json!([{ "symbol": "AAPL" }]));
873            })
874            .await;
875
876        test_client(&server)
877            .annual_with_period(INCOME_STATEMENT, "AAPL", Some("quarter"), Some(4))
878            .await
879            .unwrap();
880
881        mock.assert_async().await;
882    }
883
884    #[tokio::test]
885    async fn annual_report_form_sends_expected_request() {
886        let server = MockServer::start_async().await;
887        let mock = server
888            .mock_async(|when, then| {
889                when.method(GET)
890                    .path("/financial-reports-json")
891                    .query_param("symbol", "AAPL")
892                    .query_param("year", "2022")
893                    .query_param("period", "FY")
894                    .query_param("apikey", "test-key");
895                then.status(200).json_body(json!({ "symbol": "AAPL" }));
896            })
897            .await;
898
899        let value = test_client(&server)
900            .annual_report_form(FINANCIAL_REPORTS_JSON, "AAPL", 2022, "FY")
901            .await
902            .unwrap();
903
904        mock.assert_async().await;
905        assert_eq!(value["symbol"], "AAPL");
906    }
907
908    #[tokio::test]
909    async fn paged_endpoints_send_expected_requests() {
910        let server = MockServer::start_async().await;
911        let endpoints = [
912            GENERAL_NEWS,
913            FMP_ARTICLES,
914            FOREX_NEWS,
915            CRYPTO_NEWS,
916            INSIDER_TRADING_LATEST,
917        ];
918        let mut mocks = Vec::new();
919
920        for endpoint in endpoints {
921            mocks.push(
922                server
923                    .mock_async(|when, then| {
924                        when.method(GET)
925                            .path(format!("/{}", endpoint.path()))
926                            .query_param("page", "0")
927                            .query_param("limit", "10")
928                            .query_param("apikey", "test-key");
929                        then.status(200)
930                            .json_body(json!([{ "title": "Market news" }]));
931                    })
932                    .await,
933            );
934        }
935
936        let client = test_client(&server);
937        for endpoint in endpoints {
938            client.paged(endpoint, None, None).await.unwrap();
939        }
940
941        for mock in mocks {
942            mock.assert_async().await;
943        }
944    }
945
946    #[tokio::test]
947    async fn api_errors_are_redacted_and_structured() {
948        let server = MockServer::start_async().await;
949        server
950            .mock_async(|when, then| {
951                when.method(GET).path("/quote");
952                then.status(402)
953                    .body("Restricted Endpoint: upgrade required");
954            })
955            .await;
956        let client =
957            FmpClient::with_base_url("secret-key", format!("{}/", server.base_url())).unwrap();
958
959        let error = client.by_symbol(QUOTE, "AAPL").await.unwrap_err();
960
961        assert_eq!(error.kind(), "api_error");
962        assert!(!error.to_string().contains("secret-key"));
963        assert!(error.to_string().contains("402"));
964    }
965
966    #[tokio::test]
967    async fn rate_limited_errors_have_stable_kind_and_exit_code() {
968        let server = MockServer::start_async().await;
969        server
970            .mock_async(|when, then| {
971                when.method(GET).path("/quote");
972                then.status(429)
973                    .body("Rate limit exceeded. Please retry later.");
974            })
975            .await;
976        let client =
977            FmpClient::with_base_url("secret-key", format!("{}/", server.base_url())).unwrap();
978
979        let error = client.by_symbol(QUOTE, "AAPL").await.unwrap_err();
980
981        assert_eq!(error.kind(), "rate_limited");
982        assert_eq!(error.exit_code(), std::process::ExitCode::from(5));
983        assert!(!error.to_string().contains("secret-key"));
984        assert!(error.to_string().contains("HTTP 429"));
985        assert!(error.to_string().contains("retry later"));
986    }
987
988    #[tokio::test]
989    async fn rate_limited_body_redacts_echoed_api_key() {
990        let server = MockServer::start_async().await;
991        server
992            .mock_async(|when, then| {
993                when.method(GET).path("/quote");
994                then.status(429)
995                    .body("Rate limit exceeded for apikey=secret-key. Please retry later.");
996            })
997            .await;
998        let client =
999            FmpClient::with_base_url("secret-key", format!("{}/", server.base_url())).unwrap();
1000
1001        let error = client.by_symbol(QUOTE, "AAPL").await.unwrap_err();
1002        let message = error.to_string();
1003
1004        assert_eq!(error.kind(), "rate_limited");
1005        assert!(!message.contains("secret-key"));
1006        assert!(message.contains("apikey=***"));
1007    }
1008
1009    #[tokio::test]
1010    async fn rate_limited_body_redacts_key_before_truncating_summary() {
1011        let server = MockServer::start_async().await;
1012        let filler = "x".repeat(237);
1013        server
1014            .mock_async(|when, then| {
1015                when.method(GET).path("/quote");
1016                then.status(429)
1017                    .body(format!("{filler}secret-key trailing text"));
1018            })
1019            .await;
1020        let client =
1021            FmpClient::with_base_url("secret-key", format!("{}/", server.base_url())).unwrap();
1022
1023        let error = client.by_symbol(QUOTE, "AAPL").await.unwrap_err();
1024        let message = error.to_string();
1025
1026        assert_eq!(error.kind(), "rate_limited");
1027        assert!(!message.contains("secret-key"));
1028        assert!(message.contains("***"));
1029        assert!(message.len() < 320);
1030    }
1031
1032    #[tokio::test]
1033    async fn empty_rate_limit_body_uses_status_reason() {
1034        let server = MockServer::start_async().await;
1035        server
1036            .mock_async(|when, then| {
1037                when.method(GET).path("/quote");
1038                then.status(429).body("");
1039            })
1040            .await;
1041        let client =
1042            FmpClient::with_base_url("secret-key", format!("{}/", server.base_url())).unwrap();
1043
1044        let error = client.by_symbol(QUOTE, "AAPL").await.unwrap_err();
1045
1046        assert_eq!(error.kind(), "rate_limited");
1047        assert!(error.to_string().contains("Too Many Requests"));
1048    }
1049
1050    #[tokio::test]
1051    async fn transport_errors_do_not_include_request_url_or_api_key() {
1052        let client = FmpClient::with_base_url("secret-key", "http://127.0.0.1:9/").unwrap();
1053
1054        let error = client
1055            .annual(BALANCE_SHEET_STATEMENT, "AAPL", None)
1056            .await
1057            .unwrap_err();
1058        let message = error.to_string();
1059
1060        assert_eq!(error.kind(), "http_error");
1061        assert!(!message.contains("secret-key"));
1062        assert!(!message.contains("apikey"));
1063        assert!(!message.contains("127.0.0.1:9"));
1064    }
1065}