use std::fmt;
use std::time::Duration;
use crate::api::{ApiError, Request, BASE_URL};
const TIMEOUT: Duration = Duration::from_secs(10);
const MAX_BODY_BYTES: u64 = 1_000_000;
pub struct Client {
agent: ureq::Agent,
base_url: String,
api_key: String,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Transport(TransportError),
Api(ApiError),
}
#[derive(Debug)]
pub struct TransportError(Cause);
#[derive(Debug)]
enum Cause {
Http(ureq::Error),
UnusableKey,
PlainHttp,
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
agent: agent(TIMEOUT),
base_url: BASE_URL.to_owned(),
api_key: api_key.into().trim().to_owned(),
}
}
pub fn with_timeout(self, timeout: Duration) -> Self {
Self {
agent: agent(timeout.min(Duration::from_secs(86_400))),
..self
}
}
pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
..self
}
}
pub fn send<T>(&self, request: &Request<T>) -> Result<T, Error> {
if !self.api_key.bytes().all(|byte| byte.is_ascii_graphic()) || self.api_key.is_empty() {
return Err(Error::Transport(TransportError(Cause::UnusableKey)));
}
if plain_http(&self.base_url) {
return Err(Error::Transport(TransportError(Cause::PlainHttp)));
}
let call = self
.agent
.get(format!("{}{}", self.base_url, request.path))
.header("X-API-Key", &self.api_key);
let mut response = request
.query
.iter()
.fold(call, |call, (key, value)| call.query(key, value))
.call()
.map_err(failed)?;
let body = response
.body_mut()
.with_config()
.limit(MAX_BODY_BYTES)
.read_to_vec()
.map_err(failed)?;
let body = String::from_utf8_lossy(&body);
Ok(request.decode(response.status().as_u16(), &body)?)
}
}
fn agent(timeout: Duration) -> ureq::Agent {
ureq::Agent::config_builder()
.http_status_as_error(false)
.max_redirects(0)
.timeout_global(Some(timeout))
.build()
.into()
}
fn plain_http(base_url: &str) -> bool {
let Ok(uri) = base_url.parse::<ureq::http::Uri>() else {
return false;
};
let local = matches!(uri.host(), Some("localhost" | "127.0.0.1" | "[::1]"));
uri.scheme_str() == Some("http") && !local
}
fn failed(error: ureq::Error) -> Error {
Error::Transport(TransportError(Cause::Http(error)))
}
impl fmt::Display for TransportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
Cause::Http(error) => error.fmt(f),
Cause::UnusableKey => f.write_str("unusable API key"),
Cause::PlainHttp => f.write_str("refusing to send the key over plain http"),
}
}
}
impl std::error::Error for TransportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.0 {
Cause::Http(error) => error.source(),
Cause::UnusableKey | Cause::PlainHttp => None,
}
}
}
impl From<ApiError> for Error {
fn from(error: ApiError) -> Self {
Self::Api(error)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Transport(error) => error.fmt(f),
Self::Api(error) => error.fmt(f),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Transport(error) => error.source(),
Self::Api(error) => error.source(),
}
}
}