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
10pub struct Client {
11    agent: ureq::Agent,
12    base_url: String,
13    api_key: String,
14}
15
16#[derive(Debug)]
17#[non_exhaustive]
18pub enum Error {
19    Transport(TransportError),
20    Api(ApiError),
21}
22
23/// The request never produced a response: DNS, TLS, timeout and the like.
24///
25/// Opaque, so the HTTP library behind it can change without breaking callers.
26#[derive(Debug)]
27pub struct TransportError(ureq::Error);
28
29impl Client {
30    pub fn new(api_key: impl Into<String>) -> Self {
31        Self {
32            agent: agent(TIMEOUT),
33            base_url: BASE_URL.to_owned(),
34            api_key: api_key.into(),
35        }
36    }
37
38    /// Allows `timeout` for a whole exchange, body included, instead of 10 seconds.
39    pub fn with_timeout(self, timeout: Duration) -> Self {
40        Self {
41            agent: agent(timeout),
42            ..self
43        }
44    }
45
46    /// Points the client at another host, such as a staging stack or a mock.
47    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
48        Self {
49            base_url: base_url.into(),
50            ..self
51        }
52    }
53
54    pub fn send<T>(&self, request: &Request<T>) -> Result<T, Error> {
55        let call = self
56            .agent
57            .get(format!("{}{}", self.base_url, request.path))
58            .header("X-API-Key", &self.api_key);
59        let mut response = request
60            .query
61            .iter()
62            .fold(call, |call, (key, value)| call.query(key, value))
63            .call()
64            .map_err(failed)?;
65        let body = response.body_mut().read_to_string().map_err(failed)?;
66        Ok(request.decode(response.status().as_u16(), &body)?)
67    }
68}
69
70fn agent(timeout: Duration) -> ureq::Agent {
71    ureq::Agent::config_builder()
72        // Error statuses carry a JSON body that `Request::decode` reads.
73        .http_status_as_error(false)
74        // A redirect would carry the key to another host, so none is followed.
75        .max_redirects(0)
76        .timeout_global(Some(timeout))
77        .build()
78        .into()
79}
80
81fn failed(error: ureq::Error) -> Error {
82    Error::Transport(TransportError(error))
83}
84
85impl fmt::Display for TransportError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        self.0.fmt(f)
88    }
89}
90
91impl std::error::Error for TransportError {
92    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
93        Some(&self.0)
94    }
95}
96
97impl From<ApiError> for Error {
98    fn from(error: ApiError) -> Self {
99        Self::Api(error)
100    }
101}
102
103impl fmt::Display for Error {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            Self::Transport(error) => error.fmt(f),
107            Self::Api(error) => error.fmt(f),
108        }
109    }
110}
111
112impl std::error::Error for Error {
113    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
114        match self {
115            Self::Transport(error) => Some(error),
116            Self::Api(error) => Some(error),
117        }
118    }
119}