1use chrono::{DateTime, Utc};
10use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
11use reqwest::Client;
12use serde::{Deserialize, Serialize};
13use std::num::NonZeroU32;
14use std::time::Duration;
15use tracing::{debug, error};
16
17#[derive(Debug, Clone)]
19pub struct NewsAPIConfig {
20 pub api_key: String,
22 pub timeout: Duration,
24}
25
26impl Default for NewsAPIConfig {
27 fn default() -> Self {
28 Self {
29 api_key: String::new(),
30 timeout: Duration::from_secs(30),
31 }
32 }
33}
34
35pub struct NewsAPIClient {
37 client: Client,
38 config: NewsAPIConfig,
39 base_url: String,
40 rate_limiter: DefaultDirectRateLimiter,
41}
42
43impl NewsAPIClient {
44 pub fn new(config: NewsAPIConfig) -> Self {
46 let client = Client::builder()
47 .timeout(config.timeout)
48 .build()
49 .expect("Failed to create HTTP client");
50
51 let quota = Quota::per_hour(NonZeroU32::new(100).unwrap());
54 let rate_limiter = RateLimiter::direct(quota);
55
56 Self {
57 client,
58 config,
59 base_url: "https://newsapi.org/v2".to_string(),
60 rate_limiter,
61 }
62 }
63
64 pub async fn search(
66 &self,
67 query: &str,
68 from: Option<DateTime<Utc>>,
69 to: Option<DateTime<Utc>>,
70 language: Option<&str>,
71 sort_by: Option<&str>, ) -> Result<Vec<NewsArticle>, NewsAPIError> {
73 self.rate_limiter.until_ready().await;
74
75 let mut params = vec![
76 ("q", query.to_string()),
77 ("apiKey", self.config.api_key.clone()),
78 ];
79
80 if let Some(from_date) = from {
81 params.push(("from", from_date.format("%Y-%m-%dT%H:%M:%S").to_string()));
82 }
83
84 if let Some(to_date) = to {
85 params.push(("to", to_date.format("%Y-%m-%dT%H:%M:%S").to_string()));
86 }
87
88 if let Some(lang) = language {
89 params.push(("language", lang.to_string()));
90 }
91
92 if let Some(sort) = sort_by {
93 params.push(("sortBy", sort.to_string()));
94 }
95
96 debug!("NewsAPI search: {}", query);
97
98 let response = self
99 .client
100 .get(&format!("{}/everything", self.base_url))
101 .query(¶ms)
102 .send()
103 .await?;
104
105 if response.status().is_success() {
106 let result: NewsAPIResponse = response.json().await?;
107
108 if result.status == "ok" {
109 Ok(result.articles)
110 } else {
111 Err(NewsAPIError::ApiError(
112 result.message.unwrap_or_else(|| "Unknown error".to_string()),
113 ))
114 }
115 } else {
116 let error_text = response.text().await.unwrap_or_default();
117 error!("NewsAPI error: {}", error_text);
118 Err(NewsAPIError::ApiError(error_text))
119 }
120 }
121
122 pub async fn top_headlines(
124 &self,
125 country: Option<&str>, category: Option<&str>, sources: Option<Vec<String>>,
128 ) -> Result<Vec<NewsArticle>, NewsAPIError> {
129 self.rate_limiter.until_ready().await;
130
131 let mut params = vec![("apiKey", self.config.api_key.clone())];
132
133 if let Some(country_code) = country {
134 params.push(("country", country_code.to_string()));
135 }
136
137 if let Some(cat) = category {
138 params.push(("category", cat.to_string()));
139 }
140
141 if let Some(source_list) = sources {
142 params.push(("sources", source_list.join(",")));
143 }
144
145 let response = self
146 .client
147 .get(&format!("{}/top-headlines", self.base_url))
148 .query(¶ms)
149 .send()
150 .await?;
151
152 if response.status().is_success() {
153 let result: NewsAPIResponse = response.json().await?;
154
155 if result.status == "ok" {
156 Ok(result.articles)
157 } else {
158 Err(NewsAPIError::ApiError(
159 result.message.unwrap_or_else(|| "Unknown error".to_string()),
160 ))
161 }
162 } else {
163 let error_text = response.text().await.unwrap_or_default();
164 Err(NewsAPIError::ApiError(error_text))
165 }
166 }
167
168 pub async fn sources(
170 &self,
171 category: Option<&str>,
172 language: Option<&str>,
173 country: Option<&str>,
174 ) -> Result<Vec<NewsSource>, NewsAPIError> {
175 self.rate_limiter.until_ready().await;
176
177 let mut params = vec![("apiKey", self.config.api_key.clone())];
178
179 if let Some(cat) = category {
180 params.push(("category", cat.to_string()));
181 }
182
183 if let Some(lang) = language {
184 params.push(("language", lang.to_string()));
185 }
186
187 if let Some(country_code) = country {
188 params.push(("country", country_code.to_string()));
189 }
190
191 let response = self
192 .client
193 .get(&format!("{}/sources", self.base_url))
194 .query(¶ms)
195 .send()
196 .await?;
197
198 if response.status().is_success() {
199 #[derive(Deserialize)]
200 struct SourcesResponse {
201 status: String,
202 sources: Vec<NewsSource>,
203 }
204
205 let result: SourcesResponse = response.json().await?;
206
207 if result.status == "ok" {
208 Ok(result.sources)
209 } else {
210 Err(NewsAPIError::ApiError("Failed to fetch sources".to_string()))
211 }
212 } else {
213 let error_text = response.text().await.unwrap_or_default();
214 Err(NewsAPIError::ApiError(error_text))
215 }
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct NewsArticle {
221 pub source: NewsSourceInfo,
222 pub author: Option<String>,
223 pub title: String,
224 pub description: Option<String>,
225 pub url: String,
226 pub url_to_image: Option<String>,
227 pub published_at: String,
228 pub content: Option<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct NewsSourceInfo {
233 pub id: Option<String>,
234 pub name: String,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct NewsSource {
239 pub id: String,
240 pub name: String,
241 pub description: String,
242 pub url: String,
243 pub category: String,
244 pub language: String,
245 pub country: String,
246}
247
248#[derive(Debug, Deserialize)]
249struct NewsAPIResponse {
250 status: String,
251 #[serde(default)]
252 message: Option<String>,
253 #[serde(rename = "totalResults")]
254 total_results: Option<i32>,
255 #[serde(default)]
256 articles: Vec<NewsArticle>,
257}
258
259#[derive(Debug, thiserror::Error)]
260pub enum NewsAPIError {
261 #[error("API error: {0}")]
262 ApiError(String),
263
264 #[error("Network error: {0}")]
265 Network(#[from] reqwest::Error),
266
267 #[error("Parse error: {0}")]
268 Parse(#[from] serde_json::Error),
269
270 #[error("Rate limit exceeded")]
271 RateLimit,
272
273 #[error(transparent)]
274 Other(#[from] anyhow::Error),
275}