basispoort_sync_client/
rest.rs

1use std::fmt::Debug;
2use std::str::FromStr;
3use std::time::Duration;
4
5use bytes::Bytes;
6use reqwest::{Identity, Response, Url};
7use serde::{de::DeserializeOwned, Serialize};
8use thiserror::Error;
9use tokio::{fs::File, io::AsyncReadExt};
10use tracing::{debug, info, instrument, trace};
11
12use crate::{
13    error::{Error, ErrorResponse},
14    Result,
15};
16
17/// Build [`RestClient`] ergonomically.
18#[derive(Debug)]
19pub struct RestClientBuilder<'i> {
20    identity_cert_file: &'i str,
21    environment: Environment,
22    connect_timeout: Duration,
23    timeout: Duration,
24    min_tls_version: reqwest::tls::Version,
25}
26
27impl<'i> RestClientBuilder<'i> {
28    #[instrument]
29    pub fn new(identity_cert_file: &'i str, environment: Environment) -> Self {
30        info!(
31            "Configured environment: {environment:?}, connecting to '{}'.",
32            environment.base_url()
33        );
34
35        Self {
36            identity_cert_file,
37            environment,
38            connect_timeout: Duration::from_secs(10),
39            timeout: Duration::from_secs(30),
40            // Basispoort does not support TLS 1.3 yet, so we cannot enforce it by default :(
41            min_tls_version: reqwest::tls::Version::TLS_1_2,
42        }
43    }
44
45    /// Sets the connect timeout on the HTTP request client.
46    pub fn connect_timeout(&mut self, duration: Duration) -> &mut Self {
47        self.connect_timeout = duration;
48        self
49    }
50
51    /// Sets the request-response timeout on the HTTP request client.
52    pub fn timeout(&mut self, duration: Duration) -> &mut Self {
53        self.timeout = duration;
54        self
55    }
56
57    /// Sets the minimum TLS version. At the time of writing, Basispoort does not yet support TLS 1.3.
58    pub fn min_tls_version(&mut self, version: reqwest::tls::Version) -> &mut Self {
59        self.min_tls_version = version;
60        self
61    }
62
63    /// Build the configured [`RestClient`].
64    ///
65    /// Note that this method is `async` and returns a `Result`, as it reads the client certificate from disk.
66    #[instrument]
67    pub async fn build(self) -> Result<RestClient> {
68        let mut cert = Vec::new();
69        File::open(self.identity_cert_file)
70            .await
71            .map_err(|source| Error::OpenIdentityCertFile {
72                path: self.identity_cert_file.into(),
73                source,
74            })?
75            .read_to_end(&mut cert)
76            .await
77            .map_err(|source| Error::ReadIdentityCertFile {
78                path: self.identity_cert_file.into(),
79                source,
80            })?;
81        let identity =
82            Identity::from_pem(&cert).map_err(|source| Error::ParseIdentityCertFile {
83                path: self.identity_cert_file.into(),
84                source,
85            })?;
86
87        let client = reqwest::ClientBuilder::new()
88            .identity(identity)
89            .connect_timeout(self.connect_timeout)
90            .timeout(self.timeout)
91            .min_tls_version(self.min_tls_version)
92            .build()
93            .map_err(Error::BuildRequestClient)?;
94
95        Ok(RestClient {
96            client,
97            base_url: self.environment.base_url(),
98        })
99    }
100}
101
102/// A Basispoort environment.
103///
104/// Environments can be parsed from string, e.g. from `.env` variables.
105///
106/// Each environment has its own [`Environment::base_url`],
107/// which is used for all [`RestClient`]s [configured][`RestClientBuilder::new`] with this `Environment`.
108#[derive(Debug, Copy, Clone, Eq, PartialEq)]
109pub enum Environment {
110    Test,
111    Acceptance,
112    Staging,
113    Production,
114}
115
116/// [`Environment`] parse error.
117#[derive(Error, Debug)]
118pub enum ParseEnvironmentError {
119    #[error("'{0}' is not a valid environment string")]
120    InvalidEnvironmentString(String),
121}
122
123impl FromStr for Environment {
124    type Err = ParseEnvironmentError;
125
126    #[instrument]
127    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
128        Ok(match s {
129            "test" => Self::Test,
130            "acceptance" => Self::Acceptance,
131            "staging" => Self::Staging,
132            "production" => Self::Production,
133            s => return Err(ParseEnvironmentError::InvalidEnvironmentString(s.into())),
134        })
135    }
136}
137
138impl Environment {
139    pub fn base_url(&self) -> Url {
140        match self {
141            Environment::Test => "https://test-rest.basispoort.nl/".parse().unwrap(),
142            Environment::Acceptance => "https://acceptatie-rest.basispoort.nl/".parse().unwrap(),
143            Environment::Staging => "https://staging-rest.basispoort.nl/".parse().unwrap(),
144            Environment::Production => "https://rest.basispoort.nl/".parse().unwrap(),
145        }
146    }
147}
148
149#[derive(Clone, Debug)]
150pub struct RestClient {
151    client: reqwest::Client,
152    pub base_url: Url,
153}
154
155impl RestClient {
156    // TODO: Unit test
157    #[instrument]
158    fn make_url(&self, path: &str) -> Result<Url> {
159        self.base_url.join(path).map_err(|source| {
160            Error::ParseUrl {
161                url: path.to_owned(),
162                source,
163            }
164            .into()
165        })
166    }
167
168    #[instrument]
169    async fn error_status(&self, url: &Url, response: Response) -> Result<Response> {
170        let status = response.status();
171
172        debug!(status = status.to_string(), headers = ?response.headers());
173
174        match response.error_for_status_ref() {
175            Err(source) => {
176                let response_bytes = response.bytes().await.map_err(Error::ReceiveResponseBody)?;
177
178                let error_response = match serde_json::from_slice(&response_bytes) {
179                    Ok(error_response) => ErrorResponse::JSON(error_response),
180                    Err(_) => ErrorResponse::Plain(String::from_utf8_lossy(&response_bytes).into()),
181                };
182                Err(Error::HttpResponse {
183                    url: url.to_owned(),
184                    status,
185                    error_response,
186                    source,
187                }
188                .into())
189            }
190            Ok(_) => Ok(response),
191        }
192    }
193
194    #[instrument(skip(self, response))]
195    async fn deserialize<T: DeserializeOwned + Debug>(&self, response: Response) -> Result<T> {
196        let payload_raw = response.bytes().await.map_err(Error::ReceiveResponseBody)?;
197        trace!(?payload_raw);
198
199        // Replace empty responses by valid JSON, deserializable into `T = ()`.
200        let payload_raw = match payload_raw.len() {
201            0 => Bytes::from_static(b"null"),
202            _ => payload_raw,
203        };
204
205        let payload_deserialized =
206            serde_json::from_slice(&payload_raw).map_err(Error::DeserializeResponseBody)?;
207        debug!(?payload_deserialized);
208
209        Ok(payload_deserialized)
210    }
211
212    #[instrument]
213    pub async fn get<T: DeserializeOwned + Debug + ?Sized>(&self, path: &str) -> Result<T> {
214        let url = self.make_url(path)?;
215        trace!("GET {}", url.as_str());
216
217        let response = self
218            .client
219            .get(url.clone())
220            .send()
221            .await
222            .map_err(Error::HttpRequest)?;
223
224        let response = self.error_status(&url, response).await?;
225        self.deserialize(response).await
226    }
227
228    #[instrument(skip(payload))]
229    pub async fn post<P: Serialize + Debug + ?Sized, T: DeserializeOwned + Debug + ?Sized>(
230        &self,
231        path: &str,
232        payload: &P,
233    ) -> Result<T> {
234        let url = self.make_url(path)?;
235        trace!(?payload, "POST {}", url.as_str());
236
237        let response = self
238            .client
239            .post(url.clone())
240            .json(payload)
241            .send()
242            .await
243            .map_err(Error::HttpRequest)?;
244
245        let response = self.error_status(&url, response).await?;
246        self.deserialize(response).await
247    }
248
249    #[instrument(skip(payload))]
250    pub async fn put<P: Serialize + Debug + ?Sized, T: DeserializeOwned + Debug + ?Sized>(
251        &self,
252        path: &str,
253        payload: &P,
254    ) -> Result<T> {
255        let url = self.make_url(path)?;
256        trace!(?payload, "PUT {}", url.as_str());
257
258        let response = self
259            .client
260            .put(url.clone())
261            .json(payload)
262            .send()
263            .await
264            .map_err(Error::HttpRequest)?;
265
266        let response = self.error_status(&url, response).await?;
267        self.deserialize(response).await
268    }
269
270    #[instrument]
271    pub async fn delete<T: DeserializeOwned + Debug + ?Sized>(&self, path: &str) -> Result<T> {
272        let url = self.make_url(path)?;
273        trace!("DELETE {}", url.as_str());
274
275        let response = self
276            .client
277            .delete(url.clone())
278            .send()
279            .await
280            .map_err(Error::HttpRequest)?;
281
282        let response = self.error_status(&url, response).await?;
283        self.deserialize(response).await
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    // use super::*;
290
291    // TODO: Test make_url
292}