1use 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#[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 pub fn new(api_key: impl Into<String>) -> Result<Self> {
59 Self::with_base_url(api_key, DEFAULT_BASE_URL)
60 }
61
62 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 pub async fn endpoint(&self, endpoint: Endpoint) -> Result<Value> {
85 self.get(endpoint, &[]).await
86 }
87
88 pub async fn query(&self, endpoint: Endpoint, query: &str) -> Result<Value> {
94 self.get(endpoint, &[("query", query)]).await
95 }
96
97 pub async fn by_symbol(&self, endpoint: Endpoint, symbol: &str) -> Result<Value> {
103 self.get(endpoint, &[("symbol", symbol)]).await
104 }
105
106 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 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 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 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, ¶ms).await
159 }
160
161 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, ¶ms).await
176 }
177
178 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, ¶ms).await
194 }
195
196 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 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 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 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 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 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 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}