Skip to main content

ng_postcode/
client.rs

1//! A blocking HTTP client: the only part of the crate that performs I/O.
2
3use std::fmt;
4use std::time::Duration;
5
6use crate::api::{ApiError, Request, BASE_URL};
7
8const TIMEOUT: Duration = Duration::from_secs(10);
9
10/// Bytes of a response read before it is refused. Real answers are a few thousand.
11const 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/// The request never produced a response: DNS, TLS, timeout and the like.
27///
28/// Opaque, so the HTTP library behind it can change without breaking callers.
29#[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    /// Whitespace around the key, as read from a file, is dropped. A key no header can hold
41    /// makes every `send` fail without a request.
42    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    /// Allows `timeout` for a whole exchange, body included, instead of 10 seconds.
51    /// Anything over a day is read as a day.
52    pub fn with_timeout(self, timeout: Duration) -> Self {
53        Self {
54            // A deadline is the clock plus the timeout, which overflows for `Duration::MAX`.
55            agent: agent(timeout.min(Duration::from_secs(86_400))),
56            ..self
57        }
58    }
59
60    /// Points the client at another host, such as a staging stack or a mock.
61    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        // A key no header can hold is refused before anything is sent.
70        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        // Lossy, so a proxy's page in another encoding keeps its status and is malformed.
93        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        // Error statuses carry a JSON body that `Request::decode` reads.
101        .http_status_as_error(false)
102        // A redirect would carry the key to another host, so none is followed.
103        .max_redirects(0)
104        .timeout_global(Some(timeout))
105        .build()
106        .into()
107}
108
109/// Whether a request would carry the key unencrypted to another machine. A mock on this
110/// machine, at localhost, 127.0.0.1 or ::1, may use plain http.
111fn 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
133// Each `Display` here prints the error it wraps, so `source` skips to that error's own
134// cause: a reporter that walks the chain would otherwise print one message three times.
135impl 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}