use std::path::PathBuf;
use chrono::{Duration, Utc};
use reqwest::{header::CACHE_CONTROL, Client};
use crate::{
cache::Cache,
error::{err, Result},
forecast::Forecast,
location::Point,
types::{Position, Url},
NWS_API,
};
pub const API: &str = "https://api.weather.gov";
#[derive(Debug)]
pub struct ApiClient {
cache: Cache,
client: Client,
base_url: Url,
}
#[derive(Debug, Default)]
pub struct ApiClientBuilder {
cache_base_dir: Option<PathBuf>,
api_key: Option<String>,
api_base_url: Option<Url>,
}
impl ApiClientBuilder {
pub fn build(self) -> Result<ApiClient> {
Ok(ApiClient {
cache: Cache::with_base_dir(self.cache_base_dir)?,
client: Client::builder()
.user_agent(self.api_key.unwrap())
.build()?,
base_url: self.api_base_url.unwrap(),
})
}
pub fn cache_base_dir(mut self, path: PathBuf) -> Self {
self.cache_base_dir = Some(path);
self
}
pub fn api_key(mut self, domain: &str, email: &str) -> Self {
self.api_key = Some(format!("({}, {})", domain, email));
self
}
pub fn base_url(mut self, url: &str) -> Self {
self.api_base_url = Some(url.into());
self
}
}
impl ApiClient {
pub fn builder() -> ApiClientBuilder {
ApiClientBuilder::default()
}
pub fn nws(app: &str, api_key: &str) -> Result<Self> {
ApiClient::builder()
.base_url(NWS_API)
.api_key(app, api_key)
.build()
}
pub async fn get_point(&mut self, coordinates: Position) -> Result<Point> {
let coords = format!(
"{},{}",
round_fmt(coordinates[0], 4),
round_fmt(coordinates[1], 4)
);
let url = format!("{}/points/{}", self.base_url, coords);
let json = self.fetch_resource(&url).await?;
Ok(serde_json::from_str(&json)?)
}
pub async fn get_forecast_from_url(&mut self, url: &str) -> Result<Forecast> {
let json = self.fetch_resource(url).await?;
Ok(serde_json::from_str(&json)?)
}
async fn fetch_resource(&mut self, url: &str) -> Result<String> {
match self.cache.get(url)? {
Some(entry) => {
let max_age = entry.max_age.unwrap_or(0);
let expires_at = entry.created_at + Duration::seconds(max_age as i64);
if expires_at <= Utc::now() {
Ok(self.get_and_cache(url).await?)
} else {
Ok(entry.content)
}
}
None => Ok(self.get_and_cache(url).await?),
}
}
async fn get_and_cache(&mut self, url: &str) -> Result<String> {
let response = self.client.get(url).send().await?;
let status = response.status();
if !status.is_success() {
return err(format!(
"Unable to connect to {}: {} {}",
url,
status.as_str(),
status.canonical_reason().unwrap_or("")
)
.as_str());
}
let mut max_age = None;
if let Some(cache_control) = response.headers().get(CACHE_CONTROL) {
if let Ok(cache_control) = cache_control.to_str() {
for mut part in cache_control.split(',') {
part = part.trim();
if part.starts_with("max-age") {
if let Some((_, mut v)) = part.split_once('=') {
v = v.trim();
if let Ok(v) = v.parse::<u32>() {
max_age = Some(v);
break;
}
}
}
}
}
}
let text = response.text().await?;
self.cache.insert(url, max_age, Utc::now(), &text)?;
Ok(text)
}
}
pub fn round_fmt(f: f64, digits: u32) -> String {
let pow = 10u32.pow(digits) as f64;
let f = (f * pow).round() / pow;
format!("{0:.1$}", f, digits as usize)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_coords() {
#![allow(clippy::excessive_precision)]
let latitude: f64 = 53.473894723894738;
let longitude: f64 = -39.43784723847389;
assert_eq!("53.4739".to_string(), round_fmt(latitude, 4));
assert_eq!("-39.4378".to_string(), round_fmt(longitude, 4));
}
}