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}
37
38impl Client {
39    /// Whitespace around the key, as read from a file, is dropped. A key no header can hold
40    /// makes every `send` fail without a request.
41    pub fn new(api_key: impl Into<String>) -> Self {
42        Self {
43            agent: agent(TIMEOUT),
44            base_url: BASE_URL.to_owned(),
45            api_key: api_key.into().trim().to_owned(),
46        }
47    }
48
49    /// Allows `timeout` for a whole exchange, body included, instead of 10 seconds.
50    /// Anything over a day is read as a day.
51    pub fn with_timeout(self, timeout: Duration) -> Self {
52        Self {
53            // A deadline is the clock plus the timeout, which overflows for `Duration::MAX`.
54            agent: agent(timeout.min(Duration::from_secs(86_400))),
55            ..self
56        }
57    }
58
59    /// Points the client at another host, such as a staging stack or a mock.
60    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
61        Self {
62            base_url: base_url.into().trim_end_matches('/').to_owned(),
63            ..self
64        }
65    }
66
67    pub fn send<T>(&self, request: &Request<T>) -> Result<T, Error> {
68        // A key no header can hold is refused before anything is sent.
69        if !self.api_key.bytes().all(|byte| byte.is_ascii_graphic()) || self.api_key.is_empty() {
70            return Err(Error::Transport(TransportError(Cause::UnusableKey)));
71        }
72        let call = self
73            .agent
74            .get(format!("{}{}", self.base_url, request.path))
75            .header("X-API-Key", &self.api_key);
76        let mut response = request
77            .query
78            .iter()
79            .fold(call, |call, (key, value)| call.query(key, value))
80            .call()
81            .map_err(failed)?;
82        let body = response
83            .body_mut()
84            .with_config()
85            .limit(MAX_BODY_BYTES)
86            .read_to_vec()
87            .map_err(failed)?;
88        // Lossy, so a proxy's page in another encoding keeps its status and is malformed.
89        let body = String::from_utf8_lossy(&body);
90        Ok(request.decode(response.status().as_u16(), &body)?)
91    }
92}
93
94fn agent(timeout: Duration) -> ureq::Agent {
95    ureq::Agent::config_builder()
96        // Error statuses carry a JSON body that `Request::decode` reads.
97        .http_status_as_error(false)
98        // A redirect would carry the key to another host, so none is followed.
99        .max_redirects(0)
100        .timeout_global(Some(timeout))
101        .build()
102        .into()
103}
104
105fn failed(error: ureq::Error) -> Error {
106    Error::Transport(TransportError(Cause::Http(error)))
107}
108
109impl fmt::Display for TransportError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match &self.0 {
112            Cause::Http(error) => error.fmt(f),
113            Cause::UnusableKey => f.write_str("unusable API key"),
114        }
115    }
116}
117
118// Each `Display` here prints the error it wraps, so `source` skips to that error's own
119// cause: a reporter that walks the chain would otherwise print one message three times.
120impl std::error::Error for TransportError {
121    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
122        match &self.0 {
123            Cause::Http(error) => error.source(),
124            Cause::UnusableKey => None,
125        }
126    }
127}
128
129impl From<ApiError> for Error {
130    fn from(error: ApiError) -> Self {
131        Self::Api(error)
132    }
133}
134
135impl fmt::Display for Error {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Self::Transport(error) => error.fmt(f),
139            Self::Api(error) => error.fmt(f),
140        }
141    }
142}
143
144impl std::error::Error for Error {
145    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146        match self {
147            Self::Transport(error) => error.source(),
148            Self::Api(error) => error.source(),
149        }
150    }
151}