1use std::fmt;
4use std::time::Duration;
5
6use crate::api::{ApiError, Request, BASE_URL};
7
8const TIMEOUT: Duration = Duration::from_secs(10);
9
10const MAX_BODY_BYTES: u64 = 1_000_000;
12
13pub struct Client {
14 agent: ureq::Agent,
15 base_url: String,
16 api_key: String,
17}
18
19#[derive(Debug)]
20#[non_exhaustive]
21pub enum Error {
22 Transport(TransportError),
23 Api(ApiError),
24}
25
26#[derive(Debug)]
30pub struct TransportError(Cause);
31
32#[derive(Debug)]
33enum Cause {
34 Http(ureq::Error),
35 UnusableKey,
36 PlainHttp,
37}
38
39impl Client {
40 pub fn new(api_key: impl Into<String>) -> Self {
43 Self {
44 agent: agent(TIMEOUT),
45 base_url: BASE_URL.to_owned(),
46 api_key: api_key.into().trim().to_owned(),
47 }
48 }
49
50 pub fn with_timeout(self, timeout: Duration) -> Self {
53 Self {
54 agent: agent(timeout.min(Duration::from_secs(86_400))),
56 ..self
57 }
58 }
59
60 pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
62 Self {
63 base_url: base_url.into().trim_end_matches('/').to_owned(),
64 ..self
65 }
66 }
67
68 pub fn send<T>(&self, request: &Request<T>) -> Result<T, Error> {
69 if !self.api_key.bytes().all(|byte| byte.is_ascii_graphic()) || self.api_key.is_empty() {
71 return Err(Error::Transport(TransportError(Cause::UnusableKey)));
72 }
73 if plain_http(&self.base_url) {
74 return Err(Error::Transport(TransportError(Cause::PlainHttp)));
75 }
76 let call = self
77 .agent
78 .get(format!("{}{}", self.base_url, request.path))
79 .header("X-API-Key", &self.api_key);
80 let mut response = request
81 .query
82 .iter()
83 .fold(call, |call, (key, value)| call.query(key, value))
84 .call()
85 .map_err(failed)?;
86 let body = response
87 .body_mut()
88 .with_config()
89 .limit(MAX_BODY_BYTES)
90 .read_to_vec()
91 .map_err(failed)?;
92 let body = String::from_utf8_lossy(&body);
94 Ok(request.decode(response.status().as_u16(), &body)?)
95 }
96}
97
98fn agent(timeout: Duration) -> ureq::Agent {
99 ureq::Agent::config_builder()
100 .http_status_as_error(false)
102 .max_redirects(0)
104 .timeout_global(Some(timeout))
105 .build()
106 .into()
107}
108
109fn plain_http(base_url: &str) -> bool {
112 let Ok(uri) = base_url.parse::<ureq::http::Uri>() else {
113 return false;
114 };
115 let local = matches!(uri.host(), Some("localhost" | "127.0.0.1" | "[::1]"));
116 uri.scheme_str() == Some("http") && !local
117}
118
119fn failed(error: ureq::Error) -> Error {
120 Error::Transport(TransportError(Cause::Http(error)))
121}
122
123impl fmt::Display for TransportError {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 match &self.0 {
126 Cause::Http(error) => error.fmt(f),
127 Cause::UnusableKey => f.write_str("unusable API key"),
128 Cause::PlainHttp => f.write_str("refusing to send the key over plain http"),
129 }
130 }
131}
132
133impl std::error::Error for TransportError {
136 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
137 match &self.0 {
138 Cause::Http(error) => error.source(),
139 Cause::UnusableKey | Cause::PlainHttp => None,
140 }
141 }
142}
143
144impl From<ApiError> for Error {
145 fn from(error: ApiError) -> Self {
146 Self::Api(error)
147 }
148}
149
150impl fmt::Display for Error {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 match self {
153 Self::Transport(error) => error.fmt(f),
154 Self::Api(error) => error.fmt(f),
155 }
156 }
157}
158
159impl std::error::Error for Error {
160 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
161 match self {
162 Self::Transport(error) => error.source(),
163 Self::Api(error) => error.source(),
164 }
165 }
166}