newsapi/
api.rs

1use super::constants;
2use super::error::NewsApiError;
3use chrono::prelude::*;
4use serde::de::DeserializeOwned;
5use std::collections::HashMap;
6
7use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
8
9#[derive(Debug)]
10pub struct NewsAPIClient {
11    api_key: String,
12    parameters: HashMap<String, String>,
13    url: Option<String>,
14}
15
16impl NewsAPIClient {
17    /// Returns a NewsAPI with given api key
18    ///
19    /// # Arguments
20    ///
21    /// * `api_key` - a string that holds the api, this will be used to set X-Api-Key.
22    ///
23    pub fn new(api_key: String) -> NewsAPIClient {
24        NewsAPIClient {
25            api_key,
26            parameters: HashMap::new(),
27            url: None,
28        }
29    }
30
31    /// Build the 'fetch everything' url
32    pub fn everything(&mut self) -> &mut NewsAPIClient {
33        let allowed_params = vec![
34            "q",
35            "sources",
36            "domains",
37            "excludeDomains",
38            "from",
39            "to",
40            "language",
41            "sortBy",
42            "pageSize",
43            "page",
44        ];
45
46        self.url = Some(self.build_url(constants::EVERYTHING_URL, allowed_params));
47        self
48    }
49
50    /// Build the 'top_headlines' url
51    pub fn top_headlines(&mut self) -> &mut NewsAPIClient {
52        let allowed_params = vec!["q", "country", "category", "sources", "pageSize", "page"];
53        self.url = Some(self.build_url(constants::TOP_HEADLINES_URL, allowed_params));
54        self
55    }
56
57    /// Build the 'sources' url
58    pub fn sources(&mut self) -> &mut NewsAPIClient {
59        let allowed_params = vec!["category", "language", "country"];
60        self.url = Some(self.build_url(constants::SOURCES_URL, allowed_params));
61        self
62    }
63
64    /// Send the constructed URL to the newsapi server
65    pub async fn send_async<T>(&self) -> Result<T, NewsApiError>
66    where
67        T: DeserializeOwned,
68    {
69        if self.invalid_arguments_specified() {
70            return Err(NewsApiError::InvalidParameterCombinationError);
71        }
72
73        match &self.url {
74            Some(url) => {
75                let body = NewsAPIClient::fetch_resource_async(url, &self.api_key).await?;
76                Ok(serde_json::from_str::<T>(&body)?)
77            }
78            None => Err(NewsApiError::UndefinedUrlError),
79        }
80    }
81
82    /// Send the constructed URL to the newsapi server
83    pub fn send_sync<T>(&self) -> Result<T, NewsApiError>
84    where
85        T: DeserializeOwned,
86    {
87        if self.invalid_arguments_specified() {
88            return Err(NewsApiError::InvalidParameterCombinationError);
89        }
90
91        match &self.url {
92            Some(url) => {
93                let body = NewsAPIClient::fetch_resource_sync(url, &self.api_key)?;
94                Ok(serde_json::from_str::<T>(&body)?)
95            }
96            None => Err(NewsApiError::UndefinedUrlError),
97        }
98    }
99
100    fn build_url(&self, base_url: &str, allowed_params: Vec<&str>) -> String {
101        let mut params: Vec<String> = vec![];
102        for field in allowed_params {
103            if let Some(value) = self.parameters.get(field) {
104                params.push(format!("{}={}", field, value))
105            }
106        }
107
108        let mut url = base_url.to_owned();
109
110        if params.is_empty() {
111            url
112        } else {
113            url.push('?');
114            url.push_str(&params.join("&"));
115            url
116        }
117    }
118
119    fn invalid_arguments_specified(&self) -> bool {
120        (self.parameters.contains_key("country") || self.parameters.contains_key("category"))
121            && self.parameters.contains_key("sources")
122    }
123
124    fn handle_api_error(error_code: u16, error_string: String) -> NewsApiError {
125        match error_code {
126            400 => NewsApiError::BadRequest {
127                code: error_code,
128                message: error_string,
129            },
130            401 => NewsApiError::Unauthorized {
131                code: error_code,
132                message: error_string,
133            },
134            429 => NewsApiError::TooManyRequests {
135                code: error_code,
136                message: error_string,
137            },
138            500 => NewsApiError::ServerError {
139                code: error_code,
140                message: error_string,
141            },
142            _ => NewsApiError::GenericError {
143                code: error_code,
144                message: error_string,
145            },
146        }
147    }
148
149    fn create_user_agent() -> String {
150        concat!(
151            "rust-",
152            env!("CARGO_PKG_NAME"),
153            "/",
154            env!("CARGO_PKG_VERSION"),
155        )
156        .to_owned()
157    }
158
159    async fn fetch_resource_async(url: &str, api_key: &str) -> Result<String, NewsApiError> {
160        // TODO: create a client that can be reused
161        let client = reqwest::Client::builder()
162            .user_agent(NewsAPIClient::create_user_agent())
163            .build()?;
164
165        let resp = client.get(url).header("X-Api-Key", api_key).send().await?;
166
167        if resp.status().is_success() {
168            Ok(resp.text().await?)
169        } else {
170            Err(NewsAPIClient::handle_api_error(
171                resp.status().as_u16(),
172                resp.text().await?,
173            ))
174        }
175    }
176
177    fn fetch_resource_sync(url: &str, api_key: &str) -> Result<String, NewsApiError> {
178        // TODO: create a client that can be reused
179        let client = reqwest::blocking::Client::builder()
180            .user_agent(NewsAPIClient::create_user_agent())
181            .build()?;
182
183        let resp = client.get(url).header("X-Api-Key", api_key).send()?;
184
185        if resp.status().is_success() {
186            Ok(resp.text()?)
187        } else {
188            Err(NewsAPIClient::handle_api_error(
189                resp.status().as_u16(),
190                resp.text()?,
191            ))
192        }
193    }
194
195    /// A date and optional time for the oldest article allowed
196    pub fn from(&mut self, from: &DateTime<Utc>) -> &mut NewsAPIClient {
197        self.chronological_specification("from", from);
198        self
199    }
200
201    /// A date and optional time for the newest article allowed.
202    pub fn to(&mut self, to: &DateTime<Utc>) -> &mut NewsAPIClient {
203        self.chronological_specification("to", to);
204        self
205    }
206
207    fn chronological_specification(
208        &mut self,
209        operation: &str,
210        dt_val: &DateTime<Utc>,
211    ) -> &mut NewsAPIClient {
212        let dt_format = "%Y-%m-%dT%H:%M:%S";
213        self.parameters
214            .insert(operation.to_owned(), dt_val.format(dt_format).to_string());
215        self
216    }
217
218    ///  The domains
219    /// (e.g. bbc.co.uk, techcrunch.com, engadget.com) to which search will be restricted.
220    pub fn domains(&mut self, domains: Vec<&str>) -> &mut NewsAPIClient {
221        self.manage_domains("domains", domains);
222        self
223    }
224
225    /// The domains
226    /// (e.g. bbc.co.uk, techcrunch.com, engadget.com) from which no stories will be present in the
227    /// results.
228    pub fn exclude_domains(&mut self, domains: Vec<&str>) -> &mut NewsAPIClient {
229        self.manage_domains("excludeDomains", domains);
230        self
231    }
232
233    fn manage_domains(&mut self, operation: &str, domains: Vec<&str>) -> &mut NewsAPIClient {
234        self.parameters
235            .insert(operation.to_owned(), domains.join(","));
236        self
237    }
238
239    /// Narrow search to specific country
240    pub fn country(&mut self, country: constants::Country) -> &mut NewsAPIClient {
241        self.parameters.insert(
242            "country".to_owned(),
243            constants::COUNTRY_LOOKUP[country].to_string(),
244        );
245        self
246    }
247
248    /// Defaults to all categories - see constants.rs
249    pub fn category(&mut self, category: constants::Category) -> &mut NewsAPIClient {
250        let fmtd_category = format!("{:?}", category).to_lowercase();
251        self.parameters.insert("category".to_owned(), fmtd_category);
252        self
253    }
254
255    /// Use the /sources endpoint to locate these programmatically or look at the sources index.
256    /// Note: you can't mix this param with the country or category params.
257    /// This will be checked before calling the API but you can still get rekt!
258    pub fn with_sources(&mut self, sources: String) -> &mut NewsAPIClient {
259        self.parameters.insert("sources".to_owned(), sources);
260        self
261    }
262
263    /// Keywords or phrases to search for.
264    ///
265    /// * Surround phrases with quotes (") for exact match.
266    /// * Prepend words or phrases that must appear with a + symbol. Eg: +bitcoin
267    /// * Prepend words that must not appear with a - symbol. Eg: -bitcoin
268    /// * Alternatively you can use the AND / OR / NOT keywords, and optionally group these with parenthesis.
269    ///   e.g.: crypto AND (ethereum OR litecoin) NOT bitcoin
270    pub fn query(&mut self, query: &str) -> &mut NewsAPIClient {
271        self.parameters.insert(
272            "q".to_owned(),
273            utf8_percent_encode(query, NON_ALPHANUMERIC).to_string(),
274        );
275        self
276    }
277
278    pub fn page(&mut self, page: u32) -> &mut NewsAPIClient {
279        self.parameters.insert("page".to_owned(), page.to_string());
280        self
281    }
282
283    pub fn page_size(&mut self, size: u32) -> &mut NewsAPIClient {
284        if (1..=100).contains(&size) {
285            self.parameters
286                .insert("pageSize".to_owned(), size.to_string());
287        }
288        self
289    }
290
291    pub fn language(&mut self, language: constants::Language) -> &mut NewsAPIClient {
292        self.parameters.insert(
293            "language".to_owned(),
294            constants::LANG_LOOKUP[language].to_string(),
295        );
296        self
297    }
298
299    pub fn sort_by(&mut self, sort_by: constants::SortMethod) -> &mut NewsAPIClient {
300        self.parameters.insert(
301            "sort_by".to_owned(),
302            constants::SORT_METHOD_LOOKUP[sort_by].to_string(),
303        );
304        self
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn handle_api_error() {
314        let bad_request = NewsAPIClient::handle_api_error(400, "BadRequest".into());
315        assert_eq!(bad_request.to_string(), "BadRequest: 400 => BadRequest");
316
317        let generic_error =
318            NewsAPIClient::handle_api_error(418, "Hyper Text Coffee Pot Control Protocol".into());
319        assert_eq!(
320            generic_error.to_string(),
321            "GenericError: 418 => Hyper Text Coffee Pot Control Protocol"
322        );
323    }
324
325    #[test]
326    fn query() {
327        let mut api = NewsAPIClient::new("123".to_owned());
328        api.query("Ali loves the hoff NOT Baywatch");
329        let encoded_param = api.parameters.get("q");
330        assert_eq!(
331            encoded_param,
332            Some(&"Ali%20loves%20the%20hoff%20NOT%20Baywatch".to_string())
333        );
334    }
335
336    #[test]
337    fn build_url() {
338        let mut api = NewsAPIClient::new("123".to_owned());
339        api.language(constants::Language::English);
340        api.country(constants::Country::UnitedStatesofAmerica);
341        let expected = "https://newsapi.org/v2/sources?language=en&country=us".to_owned();
342        let allowed_params = vec!["category", "language", "country"];
343        let url = api.build_url(constants::SOURCES_URL, allowed_params);
344        assert_eq!(expected, url);
345    }
346
347    #[test]
348    fn domains() {
349        let mut api = NewsAPIClient::new("123".to_owned());
350
351        assert_eq!(api.parameters.get("domains"), None);
352        assert_eq!(api.parameters.get("excludeDomains"), None);
353
354        api.domains(vec!["www.bbc.co.uk"]);
355
356        api.exclude_domains(vec!["www.facebook.com", "www.brexitbart.com"]);
357
358        assert_eq!(
359            api.parameters.get("domains"),
360            Some(&"www.bbc.co.uk".to_owned())
361        );
362
363        assert_eq!(
364            api.parameters.get("excludeDomains"),
365            Some(&"www.facebook.com,www.brexitbart.com".to_owned())
366        );
367    }
368
369    #[test]
370    fn category() {
371        let mut api = NewsAPIClient::new("123".to_owned());
372        assert_eq!(api.parameters.get("category"), None);
373        api.category(constants::Category::Science);
374        assert_eq!(api.parameters.get("category"), Some(&"science".to_owned()));
375    }
376
377    #[test]
378    fn country() {
379        let mut api = NewsAPIClient::new("123".to_owned());
380        assert_eq!(api.parameters.get("country"), None);
381        api.country(constants::Country::Germany);
382        assert_eq!(api.parameters.get("country"), Some(&"de".to_owned()));
383    }
384
385    #[test]
386    fn language() {
387        let mut api = NewsAPIClient::new("123".to_owned());
388        api.language(constants::Language::English);
389        assert_eq!(api.parameters.get("language"), Some(&"en".to_owned()));
390    }
391
392    #[test]
393    fn to_and_from() {
394        let mut api = NewsAPIClient::new("123".to_owned());
395
396        let from = Utc.ymd(2019, 7, 8).and_hms(9, 10, 11);
397        let to = Utc.ymd(2019, 7, 9).and_hms(9, 10, 11);
398
399        api.to(&to).from(&from);
400
401        assert_eq!(
402            api.parameters.get("from"),
403            Some(&"2019-07-08T09:10:11".to_owned())
404        );
405        assert_eq!(
406            api.parameters.get("to"),
407            Some(&"2019-07-09T09:10:11".to_owned())
408        );
409    }
410
411    #[test]
412    fn new() {
413        let api = NewsAPIClient::new("123".to_string());
414        assert_eq!(api.api_key, "123".to_string());
415    }
416
417    #[test]
418    fn page() {
419        let mut api = NewsAPIClient::new("123".to_owned());
420        api.page(20);
421        assert_eq!(api.parameters.get("page"), Some(&"20".to_owned()));
422    }
423
424    #[test]
425    fn page_size() {
426        let mut api = NewsAPIClient::new("123".to_owned());
427        assert_eq!(api.parameters.get("pageSize"), None);
428        api.page_size(30);
429        assert_eq!(api.parameters.get("pageSize"), Some(&"30".to_owned()));
430        api.page_size(400);
431        assert_eq!(api.parameters.get("pageSize"), Some(&"30".to_owned()));
432    }
433}