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}