use super::constants;
use super::error::NewsApiError;
use chrono::prelude::*;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
#[derive(Debug)]
pub struct NewsAPIClient {
api_key: String,
parameters: HashMap<String, String>,
url: Option<String>,
}
impl NewsAPIClient {
pub fn new(api_key: String) -> NewsAPIClient {
NewsAPIClient {
api_key,
parameters: HashMap::new(),
url: None,
}
}
pub fn everything(&mut self) -> &mut NewsAPIClient {
let allowed_params = vec![
"q",
"sources",
"domains",
"excludeDomains",
"from",
"to",
"language",
"sortBy",
"pageSize",
"page",
];
self.url = Some(self.build_url(constants::EVERYTHING_URL, allowed_params));
self
}
pub fn top_headlines(&mut self) -> &mut NewsAPIClient {
let allowed_params = vec!["q", "country", "category", "sources", "pageSize", "page"];
self.url = Some(self.build_url(constants::TOP_HEADLINES_URL, allowed_params));
self
}
pub fn sources(&mut self) -> &mut NewsAPIClient {
let allowed_params = vec!["category", "language", "country"];
self.url = Some(self.build_url(constants::SOURCES_URL, allowed_params));
self
}
pub async fn send_async<T>(&self) -> Result<T, NewsApiError>
where
T: DeserializeOwned,
{
if self.invalid_arguments_specified() {
return Err(NewsApiError::InvalidParameterCombinationError);
}
match &self.url {
Some(url) => {
let body = NewsAPIClient::fetch_resource_async(url, &self.api_key).await?;
Ok(serde_json::from_str::<T>(&body)?)
}
None => Err(NewsApiError::UndefinedUrlError),
}
}
pub fn send_sync<T>(&self) -> Result<T, NewsApiError>
where
T: DeserializeOwned,
{
if self.invalid_arguments_specified() {
return Err(NewsApiError::InvalidParameterCombinationError);
}
match &self.url {
Some(url) => {
let body = NewsAPIClient::fetch_resource_sync(url, &self.api_key)?;
Ok(serde_json::from_str::<T>(&body)?)
}
None => Err(NewsApiError::UndefinedUrlError),
}
}
fn build_url(&self, base_url: &str, allowed_params: Vec<&str>) -> String {
let mut params: Vec<String> = vec![];
for field in allowed_params {
if let Some(value) = self.parameters.get(field) {
params.push(format!("{field}={value}"))
}
}
let mut url = base_url.to_owned();
if params.is_empty() {
url
} else {
url.push('?');
url.push_str(¶ms.join("&"));
url
}
}
fn invalid_arguments_specified(&self) -> bool {
(self.parameters.contains_key("country") || self.parameters.contains_key("category"))
&& self.parameters.contains_key("sources")
}
fn handle_api_error(error_code: u16, error_string: String) -> NewsApiError {
match error_code {
400 => NewsApiError::BadRequest {
code: error_code,
message: error_string,
},
401 => NewsApiError::Unauthorized {
code: error_code,
message: error_string,
},
429 => NewsApiError::TooManyRequests {
code: error_code,
message: error_string,
},
500 => NewsApiError::ServerError {
code: error_code,
message: error_string,
},
_ => NewsApiError::GenericError {
code: error_code,
message: error_string,
},
}
}
fn create_user_agent() -> String {
concat!(
"rust-",
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
)
.to_owned()
}
async fn fetch_resource_async(url: &str, api_key: &str) -> Result<String, NewsApiError> {
let client = reqwest::Client::builder()
.user_agent(NewsAPIClient::create_user_agent())
.build()?;
let resp = client.get(url).header("X-Api-Key", api_key).send().await?;
if resp.status().is_success() {
Ok(resp.text().await?)
} else {
Err(NewsAPIClient::handle_api_error(
resp.status().as_u16(),
resp.text().await?,
))
}
}
fn fetch_resource_sync(url: &str, api_key: &str) -> Result<String, NewsApiError> {
let client = reqwest::blocking::Client::builder()
.user_agent(NewsAPIClient::create_user_agent())
.build()?;
let resp = client.get(url).header("X-Api-Key", api_key).send()?;
if resp.status().is_success() {
Ok(resp.text()?)
} else {
Err(NewsAPIClient::handle_api_error(
resp.status().as_u16(),
resp.text()?,
))
}
}
pub fn from(&mut self, from: &DateTime<Utc>) -> &mut NewsAPIClient {
self.chronological_specification("from", from);
self
}
pub fn to(&mut self, to: &DateTime<Utc>) -> &mut NewsAPIClient {
self.chronological_specification("to", to);
self
}
fn chronological_specification(
&mut self,
operation: &str,
dt_val: &DateTime<Utc>,
) -> &mut NewsAPIClient {
let dt_format = "%Y-%m-%dT%H:%M:%S";
self.parameters
.insert(operation.to_owned(), dt_val.format(dt_format).to_string());
self
}
pub fn domains(&mut self, domains: Vec<&str>) -> &mut NewsAPIClient {
self.manage_domains("domains", domains);
self
}
pub fn exclude_domains(&mut self, domains: Vec<&str>) -> &mut NewsAPIClient {
self.manage_domains("excludeDomains", domains);
self
}
fn manage_domains(&mut self, operation: &str, domains: Vec<&str>) -> &mut NewsAPIClient {
self.parameters
.insert(operation.to_owned(), domains.join(","));
self
}
pub fn country(&mut self, country: constants::Country) -> &mut NewsAPIClient {
self.parameters.insert(
"country".to_owned(),
constants::COUNTRY_LOOKUP[country].to_string(),
);
self
}
pub fn category(&mut self, category: constants::Category) -> &mut NewsAPIClient {
let fmtd_category = format!("{category:?}").to_lowercase();
self.parameters.insert("category".to_owned(), fmtd_category);
self
}
pub fn with_sources(&mut self, sources: String) -> &mut NewsAPIClient {
self.parameters.insert("sources".to_owned(), sources);
self
}
pub fn query(&mut self, query: &str) -> &mut NewsAPIClient {
self.parameters.insert(
"q".to_owned(),
utf8_percent_encode(query, NON_ALPHANUMERIC).to_string(),
);
self
}
pub fn page(&mut self, page: u32) -> &mut NewsAPIClient {
self.parameters.insert("page".to_owned(), page.to_string());
self
}
pub fn page_size(&mut self, size: u32) -> &mut NewsAPIClient {
if (1..=100).contains(&size) {
self.parameters
.insert("pageSize".to_owned(), size.to_string());
}
self
}
pub fn language(&mut self, language: constants::Language) -> &mut NewsAPIClient {
self.parameters.insert(
"language".to_owned(),
constants::LANG_LOOKUP[language].to_string(),
);
self
}
pub fn sort_by(&mut self, sort_by: constants::SortMethod) -> &mut NewsAPIClient {
self.parameters.insert(
"sort_by".to_owned(),
constants::SORT_METHOD_LOOKUP[sort_by].to_string(),
);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handle_api_error() {
let bad_request = NewsAPIClient::handle_api_error(400, "BadRequest".into());
assert_eq!(bad_request.to_string(), "BadRequest: 400 => BadRequest");
let generic_error =
NewsAPIClient::handle_api_error(418, "Hyper Text Coffee Pot Control Protocol".into());
assert_eq!(
generic_error.to_string(),
"GenericError: 418 => Hyper Text Coffee Pot Control Protocol"
);
}
#[test]
fn query() {
let mut api = NewsAPIClient::new("123".to_owned());
api.query("Ali loves the hoff NOT Baywatch");
let encoded_param = api.parameters.get("q");
assert_eq!(
encoded_param,
Some(&"Ali%20loves%20the%20hoff%20NOT%20Baywatch".to_string())
);
}
#[test]
fn build_url() {
let mut api = NewsAPIClient::new("123".to_owned());
api.language(constants::Language::English);
api.country(constants::Country::UnitedStatesofAmerica);
let expected = "https://newsapi.org/v2/sources?language=en&country=us".to_owned();
let allowed_params = vec!["category", "language", "country"];
let url = api.build_url(constants::SOURCES_URL, allowed_params);
assert_eq!(expected, url);
}
#[test]
fn domains() {
let mut api = NewsAPIClient::new("123".to_owned());
assert_eq!(api.parameters.get("domains"), None);
assert_eq!(api.parameters.get("excludeDomains"), None);
api.domains(vec!["www.bbc.co.uk"]);
api.exclude_domains(vec!["www.facebook.com", "www.brexitbart.com"]);
assert_eq!(
api.parameters.get("domains"),
Some(&"www.bbc.co.uk".to_owned())
);
assert_eq!(
api.parameters.get("excludeDomains"),
Some(&"www.facebook.com,www.brexitbart.com".to_owned())
);
}
#[test]
fn category() {
let mut api = NewsAPIClient::new("123".to_owned());
assert_eq!(api.parameters.get("category"), None);
api.category(constants::Category::Science);
assert_eq!(api.parameters.get("category"), Some(&"science".to_owned()));
}
#[test]
fn country() {
let mut api = NewsAPIClient::new("123".to_owned());
assert_eq!(api.parameters.get("country"), None);
api.country(constants::Country::Germany);
assert_eq!(api.parameters.get("country"), Some(&"de".to_owned()));
}
#[test]
fn language() {
let mut api = NewsAPIClient::new("123".to_owned());
api.language(constants::Language::English);
assert_eq!(api.parameters.get("language"), Some(&"en".to_owned()));
}
#[test]
fn to_and_from() {
let mut api = NewsAPIClient::new("123".to_owned());
let from = Utc.with_ymd_and_hms(2019, 7, 8, 9, 10, 11).unwrap();
let to = Utc.with_ymd_and_hms(2019, 7, 9, 9, 10, 11).unwrap();
api.to(&to).from(&from);
assert_eq!(
api.parameters.get("from"),
Some(&"2019-07-08T09:10:11".to_owned())
);
assert_eq!(
api.parameters.get("to"),
Some(&"2019-07-09T09:10:11".to_owned())
);
}
#[test]
fn new() {
let api = NewsAPIClient::new("123".to_string());
assert_eq!(api.api_key, "123".to_string());
}
#[test]
fn page() {
let mut api = NewsAPIClient::new("123".to_owned());
api.page(20);
assert_eq!(api.parameters.get("page"), Some(&"20".to_owned()));
}
#[test]
fn page_size() {
let mut api = NewsAPIClient::new("123".to_owned());
assert_eq!(api.parameters.get("pageSize"), None);
api.page_size(30);
assert_eq!(api.parameters.get("pageSize"), Some(&"30".to_owned()));
api.page_size(400);
assert_eq!(api.parameters.get("pageSize"), Some(&"30".to_owned()));
}
}