Skip to main content

reqwest_rest/
rest_client.rs

1use displaydoc::Display;
2use serde::{Serialize, de::DeserializeOwned};
3use std::{fmt::Debug, pin::Pin};
4
5// These types are part of our public API
6pub use reqwest::{Error as ReqwestError, Method, Response, StatusCode, header::HeaderMap};
7pub use reqwest_middleware::{ClientWithMiddleware, Error as ReqwestMiddlewareError};
8pub use serde_json::Error as SerdeJsonError;
9pub use serde_qs::Error as SerdeQsError;
10pub use url::{ParseError, Url};
11
12/// A reqwest client (with middleware) and a base url, which can make "REST" (http-json)
13/// requests and parse responses according to serde-schema.
14///
15/// All errors retain context about the url, method, and route requested, and at what point
16/// in the process the failure occurred.
17///
18/// The RestClient is agnostic about how you initialize ClientWithMiddleware and Url.
19/// If you like you may use `CommonConfig` to setup timeouts and retries on a `reqwest::ClientBuilder`.
20pub struct RestClient {
21    /// The client to use to make requests
22    pub client: ClientWithMiddleware,
23    /// The base url of the API, to which routes are joined
24    pub url: Url,
25}
26
27impl RestClient {
28    /// Makes a request for given method, route, and data.
29    /// If it is a GET request, the data is url encoded as query params, otherwise it becomes json in the request body.
30    pub async fn request<S: Serialize, T: DeserializeOwned>(
31        &self,
32        method: Method,
33        route: impl AsRef<str> + Debug,
34        data: &S,
35    ) -> Result<T, Error> {
36        self.request_with_headers(method, route, HeaderMap::new(), data)
37            .await
38    }
39
40    /// Makes a request for given method, route, and data, with given http headers.
41    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, headers, data)))]
42    pub async fn request_with_headers<S: Serialize, T: DeserializeOwned>(
43        &self,
44        method: Method,
45        route: impl AsRef<str> + Debug,
46        headers: HeaderMap,
47        data: &S,
48    ) -> Result<T, Error> {
49        let mut url = join_url(&self.url, route.as_ref())?;
50
51        // GET and DELETE requests do not support a request body
52        let data_in_query_string = method == Method::GET || method == Method::DELETE;
53
54        if data_in_query_string {
55            let query = serde_qs::to_string(data)
56                .map_err(|err| Error::QueryString(method.clone(), url.clone(), Box::new(err)))?;
57            if !query.is_empty() {
58                url.set_query(Some(&query));
59            }
60        }
61
62        let mut req = self.client.request(method.clone(), url.clone());
63
64        if !data_in_query_string {
65            req = req.json(data);
66        }
67
68        req = req.headers(headers);
69
70        let resp = req
71            .send()
72            .await
73            .map_err(|err| Error::Request(method.clone(), url.clone(), Box::new(err)))?;
74
75        let status = resp.status();
76
77        #[cfg(feature = "tracing")]
78        tracing::debug!("{} {} => {}", method, url, status);
79
80        if resp.error_for_status_ref().is_ok() {
81            // Successful response
82            let body_text = resp
83                .text()
84                .await
85                .map_err(|err| Error::Request(method.clone(), url.clone(), Box::new(err.into())))?;
86
87            // If body_text is the empty string, that does not deserialize to valid json ever, at least in serde_json implementation.
88            // But sometimes it's useful for Option<JsonValue>, or more generally Option<T>. to match that successfully, because some services
89            // have empty body text on a success response.
90            // So, if body_text is empty, we'll mask it to "null" before parsing as json, so that there is a way that
91            // an empty body in a success response can be valid.
92            let body_str: &str = if body_text.is_empty() {
93                "null"
94            } else {
95                &body_text
96            };
97
98            let body: T =
99                serde_json::from_str(body_str).map_err(|err| Error::Deserialization(method, url, status, if body_text.is_empty() {
100                    <serde_json::Error as serde::de::Error>::custom("response body text was empty, but 'null' was not a valid value for the expected response schema: {err}")
101                    } else { err }))?;
102            Ok(body)
103        } else {
104            // In the basic client, we do no further processing of the error response, and hand it to the caller.
105            Err(Error::Response(method, url, status, Box::pin(resp)))
106        }
107    }
108}
109
110/// An error which occurs when using the BasicClient
111///
112/// This can be a serialization problem, a problem making the request, or an unsuccessful response.
113/// Unsuccessful means any non 2xx response code.
114#[derive(Debug, Display)]
115pub enum Error {
116    /// Url cannot be a base: {0}
117    UrlCannotBeABase(Url),
118    /// Query string encoding: {0} {1} => {2}
119    QueryString(Method, Url, Box<SerdeQsError>),
120    /// Reqwest: {0} {1} => {2}
121    Request(Method, Url, Box<ReqwestMiddlewareError>),
122    /// Error serializing json for request {0} {1} => {2}
123    Serialization(Method, Url, SerdeJsonError),
124    /// Error deserializing a response: {0} {1} => {2}: {3}
125    Deserialization(Method, Url, StatusCode, SerdeJsonError),
126    /// Error Response: {0} {1} => {2}
127    Response(Method, Url, StatusCode, Pin<Box<Response>>),
128}
129
130/// When making requests, this client works by appending routes as needed to the base url of the api
131/// This function is a helper to work around issues with url::join, where it is sensitive to trailing slashes
132/// in the base API, but that is potentially confusing and not usually what we want.
133/// https://docs.rs/url/latest/url/struct.Url.html#method.join
134/// https://github.com/servo/rust-url/issues/333
135/// Instead we clone the original url and append path segments using the path_segments_mut api.
136/// We have to split the route on `/` for this to work correctly, otherwise the library will escape the `/` characters...
137/// We modeled the splitting procedure on the code in url lib here:
138/// https://docs.rs/url/2.5.0/src/url/lib.rs.html#1351
139pub fn join_url(url: &Url, route: &str) -> Result<Url, Error> {
140    let mut result = url.clone();
141    {
142        let Ok(mut path_segments_mut) = result.path_segments_mut() else {
143            return Err(Error::UrlCannotBeABase(result));
144        };
145
146        // Remove the last segment if it is empty, this makes our code tolerate trailing `/`'s
147        path_segments_mut.pop_if_empty();
148
149        // Remove any leading `/` from the route, the library is going to add them.
150        let route = route.strip_prefix('/').unwrap_or(route);
151        for segment in route.split('/') {
152            path_segments_mut.push(segment);
153        }
154    }
155    Ok(result)
156}