fmp_rs/endpoints/
news.rs

1//! News endpoints
2
3use crate::Result;
4use crate::client::FmpClient;
5use crate::models::news::{NewsArticle, PressRelease};
6use serde::Serialize;
7
8/// News API endpoints
9pub struct News {
10    client: FmpClient,
11}
12
13#[derive(Debug, Clone, Serialize)]
14struct NewsQuery {
15    #[serde(skip_serializing_if = "Option::is_none")]
16    page: Option<u32>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    limit: Option<u32>,
19}
20
21impl News {
22    pub(crate) fn new(client: FmpClient) -> Self {
23        Self { client }
24    }
25
26    /// Get general stock news
27    ///
28    /// # Arguments
29    /// * `page` - Page number (optional)
30    /// * `limit` - Number of results per page (optional)
31    ///
32    /// # Example
33    /// ```no_run
34    /// # use fmp_rs::FmpClient;
35    /// # #[tokio::main]
36    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
37    /// let client = FmpClient::new()?;
38    /// let news = client.news().get_stock_news(Some(0), Some(50)).await?;
39    /// for article in news {
40    ///     println!("{}: {}", article.symbol.unwrap_or_default(), article.title);
41    /// }
42    /// # Ok(())
43    /// # }
44    /// ```
45    pub async fn get_stock_news(
46        &self,
47        page: Option<u32>,
48        limit: Option<u32>,
49    ) -> Result<Vec<NewsArticle>> {
50        let query = NewsQuery { page, limit };
51        self.client.get_with_query("v3/fmp/articles", &query).await
52    }
53
54    /// Get news for a specific symbol
55    ///
56    /// # Arguments
57    /// * `symbol` - Stock symbol (e.g., "AAPL")
58    /// * `page` - Page number (optional)
59    /// * `limit` - Number of results per page (optional)
60    ///
61    /// # Example
62    /// ```no_run
63    /// # use fmp_rs::FmpClient;
64    /// # #[tokio::main]
65    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
66    /// let client = FmpClient::new()?;
67    /// let news = client.news().get_symbol_news("AAPL", Some(0), Some(10)).await?;
68    /// for article in news {
69    ///     println!("{}: {}", article.published_date, article.title);
70    /// }
71    /// # Ok(())
72    /// # }
73    /// ```
74    pub async fn get_symbol_news(
75        &self,
76        symbol: &str,
77        page: Option<u32>,
78        limit: Option<u32>,
79    ) -> Result<Vec<NewsArticle>> {
80        let query = NewsQuery { page, limit };
81        self.client
82            .get_with_query(&format!("v3/stock_news?tickers={}", symbol), &query)
83            .await
84    }
85
86    /// Get news for multiple symbols
87    ///
88    /// # Arguments
89    /// * `symbols` - List of stock symbols
90    /// * `page` - Page number (optional)
91    /// * `limit` - Number of results per page (optional)
92    ///
93    /// # Example
94    /// ```no_run
95    /// # use fmp_rs::FmpClient;
96    /// # #[tokio::main]
97    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
98    /// let client = FmpClient::new()?;
99    /// let symbols = vec!["AAPL", "MSFT", "GOOGL"];
100    /// let news = client.news().get_symbols_news(&symbols, Some(0), Some(20)).await?;
101    /// for article in news {
102    ///     println!("{}: {}", article.symbol.unwrap_or_default(), article.title);
103    /// }
104    /// # Ok(())
105    /// # }
106    /// ```
107    pub async fn get_symbols_news(
108        &self,
109        symbols: &[&str],
110        page: Option<u32>,
111        limit: Option<u32>,
112    ) -> Result<Vec<NewsArticle>> {
113        let tickers = symbols.join(",");
114        let query = NewsQuery { page, limit };
115        self.client
116            .get_with_query(&format!("v3/stock_news?tickers={}", tickers), &query)
117            .await
118    }
119
120    /// Get cryptocurrency news
121    ///
122    /// # Arguments
123    /// * `page` - Page number (optional)
124    /// * `limit` - Number of results per page (optional)
125    ///
126    /// # Example
127    /// ```no_run
128    /// # use fmp_rs::FmpClient;
129    /// # #[tokio::main]
130    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
131    /// let client = FmpClient::new()?;
132    /// let news = client.news().get_crypto_news(Some(0), Some(20)).await?;
133    /// for article in news {
134    ///     println!("{}: {}", article.symbol.unwrap_or_default(), article.title);
135    /// }
136    /// # Ok(())
137    /// # }
138    /// ```
139    pub async fn get_crypto_news(
140        &self,
141        page: Option<u32>,
142        limit: Option<u32>,
143    ) -> Result<Vec<NewsArticle>> {
144        let query = NewsQuery { page, limit };
145        self.client.get_with_query("v4/crypto_news", &query).await
146    }
147
148    /// Get forex news
149    ///
150    /// # Arguments
151    /// * `page` - Page number (optional)
152    /// * `limit` - Number of results per page (optional)
153    ///
154    /// # Example
155    /// ```no_run
156    /// # use fmp_rs::FmpClient;
157    /// # #[tokio::main]
158    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
159    /// let client = FmpClient::new()?;
160    /// let news = client.news().get_forex_news(Some(0), Some(20)).await?;
161    /// for article in news {
162    ///     println!("{}: {}", article.symbol.unwrap_or_default(), article.title);
163    /// }
164    /// # Ok(())
165    /// # }
166    /// ```
167    pub async fn get_forex_news(
168        &self,
169        page: Option<u32>,
170        limit: Option<u32>,
171    ) -> Result<Vec<NewsArticle>> {
172        let query = NewsQuery { page, limit };
173        self.client.get_with_query("v4/forex_news", &query).await
174    }
175
176    /// Get general news (all types)
177    ///
178    /// # Arguments
179    /// * `page` - Page number (optional)
180    /// * `limit` - Number of results per page (optional)
181    ///
182    /// # Example
183    /// ```no_run
184    /// # use fmp_rs::FmpClient;
185    /// # #[tokio::main]
186    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
187    /// let client = FmpClient::new()?;
188    /// let news = client.news().get_general_news(Some(0), Some(50)).await?;
189    /// for article in news {
190    ///     println!("{}: {}", article.published_date, article.title);
191    /// }
192    /// # Ok(())
193    /// # }
194    /// ```
195    pub async fn get_general_news(
196        &self,
197        page: Option<u32>,
198        limit: Option<u32>,
199    ) -> Result<Vec<NewsArticle>> {
200        let query = NewsQuery { page, limit };
201        self.client.get_with_query("v4/general_news", &query).await
202    }
203
204    /// Get press releases for a symbol
205    ///
206    /// # Arguments
207    /// * `symbol` - Stock symbol (e.g., "AAPL")
208    /// * `page` - Page number (optional)
209    /// * `limit` - Number of results per page (optional)
210    ///
211    /// # Example
212    /// ```no_run
213    /// # use fmp_rs::FmpClient;
214    /// # #[tokio::main]
215    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
216    /// let client = FmpClient::new()?;
217    /// let releases = client.news().get_press_releases("AAPL", Some(0), Some(10)).await?;
218    /// for release in releases {
219    ///     println!("{}: {}", release.date, release.title);
220    /// }
221    /// # Ok(())
222    /// # }
223    /// ```
224    pub async fn get_press_releases(
225        &self,
226        symbol: &str,
227        page: Option<u32>,
228        limit: Option<u32>,
229    ) -> Result<Vec<PressRelease>> {
230        let query = NewsQuery { page, limit };
231        self.client
232            .get_with_query(&format!("v3/press-releases/{}", symbol), &query)
233            .await
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_new() {
243        let client = FmpClient::builder().api_key("test_key").build().unwrap();
244        let news = News::new(client);
245        assert!(std::ptr::addr_of!(news.client).is_null() == false);
246    }
247
248    #[tokio::test]
249    #[ignore] // Requires API key
250    async fn test_get_stock_news() {
251        let client = FmpClient::new().unwrap();
252        let result = client.news().get_stock_news(Some(0), Some(5)).await;
253        assert!(result.is_ok());
254        let news = result.unwrap();
255        assert!(!news.is_empty());
256    }
257
258    #[tokio::test]
259    #[ignore] // Requires API key
260    async fn test_get_symbol_news() {
261        let client = FmpClient::new().unwrap();
262        let result = client
263            .news()
264            .get_symbol_news("AAPL", Some(0), Some(5))
265            .await;
266        assert!(result.is_ok());
267        let news = result.unwrap();
268        assert!(!news.is_empty());
269    }
270
271    #[tokio::test]
272    #[ignore] // Requires API key
273    async fn test_get_crypto_news() {
274        let client = FmpClient::new().unwrap();
275        let result = client.news().get_crypto_news(Some(0), Some(5)).await;
276        assert!(result.is_ok());
277    }
278
279    #[tokio::test]
280    #[ignore] // Requires API key
281    async fn test_get_forex_news() {
282        let client = FmpClient::new().unwrap();
283        let result = client.news().get_forex_news(Some(0), Some(5)).await;
284        assert!(result.is_ok());
285    }
286
287    #[tokio::test]
288    #[ignore] // Requires API key
289    async fn test_get_press_releases() {
290        let client = FmpClient::new().unwrap();
291        let result = client
292            .news()
293            .get_press_releases("AAPL", Some(0), Some(5))
294            .await;
295        assert!(result.is_ok());
296    }
297
298    #[tokio::test]
299    #[ignore] // Requires API key
300    async fn test_get_symbols_news() {
301        let client = FmpClient::new().unwrap();
302        let result = client
303            .news()
304            .get_symbols_news(&["AAPL", "MSFT"], Some(0), Some(10))
305            .await;
306        assert!(result.is_ok());
307        let news = result.unwrap();
308        // Should return news for the specified symbols
309        if !news.is_empty() {
310            assert!(news.iter().any(|n| {
311                n.symbol
312                    .as_ref()
313                    .map_or(false, |s| s == "AAPL" || s == "MSFT")
314            }));
315        }
316    }
317
318    #[tokio::test]
319    #[ignore] // Requires API key
320    async fn test_get_general_news() {
321        let client = FmpClient::new().unwrap();
322        let result = client.news().get_general_news(Some(0), Some(20)).await;
323        assert!(result.is_ok());
324        let news = result.unwrap();
325        assert!(!news.is_empty());
326        // General news should have titles and dates
327        assert!(news[0].title.len() > 0);
328        assert!(news[0].published_date.len() > 0);
329    }
330
331    // Edge case tests
332    #[tokio::test]
333    #[ignore] // Requires API key
334    async fn test_get_symbol_news_invalid_symbol() {
335        let client = FmpClient::new().unwrap();
336        let result = client
337            .news()
338            .get_symbol_news("INVALID_SYMBOL_12345", Some(0), Some(5))
339            .await;
340        // Should handle gracefully - either empty result or error
341        match result {
342            Ok(news) => assert!(news.is_empty()),
343            Err(_) => {} // API may return error for invalid symbols
344        }
345    }
346
347    #[tokio::test]
348    #[ignore] // Requires API key
349    async fn test_get_news_pagination_limits() {
350        let client = FmpClient::new().unwrap();
351
352        // Test large page number
353        let result = client.news().get_stock_news(Some(1000), Some(5)).await;
354        match result {
355            Ok(news) => assert!(news.is_empty()), // Should return empty for high page numbers
356            Err(_) => {}                          // API may return error
357        }
358
359        // Test large limit
360        let result = client.news().get_stock_news(Some(0), Some(1000)).await;
361        assert!(result.is_ok()); // Should handle large limits
362    }
363
364    #[tokio::test]
365    #[ignore] // Requires API key
366    async fn test_get_symbols_news_empty_array() {
367        let client = FmpClient::new().unwrap();
368        let result = client.news().get_symbols_news(&[], Some(0), Some(5)).await;
369        // Should handle empty symbols array
370        match result {
371            Ok(news) => assert!(news.is_empty()),
372            Err(_) => {} // API may return error for empty input
373        }
374    }
375
376    #[tokio::test]
377    #[ignore] // Requires API key
378    async fn test_get_symbols_news_mixed_valid_invalid() {
379        let client = FmpClient::new().unwrap();
380        let result = client
381            .news()
382            .get_symbols_news(&["AAPL", "INVALID_SYM", "MSFT"], Some(0), Some(10))
383            .await;
384        assert!(result.is_ok());
385        let news = result.unwrap();
386        // Should return news for valid symbols, may skip invalid ones
387        if !news.is_empty() {
388            assert!(news.iter().any(|n| {
389                n.symbol
390                    .as_ref()
391                    .map_or(false, |s| s == "AAPL" || s == "MSFT")
392            }));
393        }
394    }
395
396    #[tokio::test]
397    #[ignore] // Requires API key
398    async fn test_get_news_zero_page_limit() {
399        let client = FmpClient::new().unwrap();
400
401        // Test zero limit
402        let result = client.news().get_stock_news(Some(0), Some(0)).await;
403        match result {
404            Ok(news) => assert!(news.is_empty()),
405            Err(_) => {} // API may return error for zero limit
406        }
407    }
408
409    #[tokio::test]
410    #[ignore] // Requires API key
411    async fn test_get_press_releases_invalid_symbol() {
412        let client = FmpClient::new().unwrap();
413        let result = client
414            .news()
415            .get_press_releases("INVALID_SYMBOL_12345", Some(0), Some(5))
416            .await;
417        // Should handle invalid symbols gracefully
418        match result {
419            Ok(releases) => assert!(releases.is_empty()),
420            Err(_) => {} // API may return error for invalid symbols
421        }
422    }
423
424    #[tokio::test]
425    #[ignore] // Requires API key
426    async fn test_news_data_validation() {
427        let client = FmpClient::new().unwrap();
428        let result = client.news().get_stock_news(Some(0), Some(5)).await;
429        assert!(result.is_ok());
430        let news = result.unwrap();
431
432        if !news.is_empty() {
433            let article = &news[0];
434            // Validate required fields are present
435            assert!(!article.title.is_empty());
436            assert!(!article.published_date.is_empty());
437            // URL should be valid if present
438            if !article.url.is_empty() {
439                let url = &article.url;
440                assert!(url.starts_with("http://") || url.starts_with("https://"));
441            }
442        }
443    }
444}