reqwest-rest 0.1.0

An opinionated utility to help with creating (and configuring) one-off idiomatic REST API clients.
Documentation
use displaydoc::Display;
use serde::{Serialize, de::DeserializeOwned};
use std::{fmt::Debug, pin::Pin};

// These types are part of our public API
pub use reqwest::{Error as ReqwestError, Method, Response, StatusCode, header::HeaderMap};
pub use reqwest_middleware::{ClientWithMiddleware, Error as ReqwestMiddlewareError};
pub use serde_json::Error as SerdeJsonError;
pub use serde_qs::Error as SerdeQsError;
pub use url::{ParseError, Url};

/// A reqwest client (with middleware) and a base url, which can make "REST" (http-json)
/// requests and parse responses according to serde-schema.
///
/// All errors retain context about the url, method, and route requested, and at what point
/// in the process the failure occurred.
///
/// The RestClient is agnostic about how you initialize ClientWithMiddleware and Url.
/// If you like you may use `CommonConfig` to setup timeouts and retries on a `reqwest::ClientBuilder`.
pub struct RestClient {
    /// The client to use to make requests
    pub client: ClientWithMiddleware,
    /// The base url of the API, to which routes are joined
    pub url: Url,
}

impl RestClient {
    /// Makes a request for given method, route, and data.
    /// If it is a GET request, the data is url encoded as query params, otherwise it becomes json in the request body.
    pub async fn request<S: Serialize, T: DeserializeOwned>(
        &self,
        method: Method,
        route: impl AsRef<str> + Debug,
        data: &S,
    ) -> Result<T, Error> {
        self.request_with_headers(method, route, HeaderMap::new(), data)
            .await
    }

    /// Makes a request for given method, route, and data, with given http headers.
    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, headers, data)))]
    pub async fn request_with_headers<S: Serialize, T: DeserializeOwned>(
        &self,
        method: Method,
        route: impl AsRef<str> + Debug,
        headers: HeaderMap,
        data: &S,
    ) -> Result<T, Error> {
        let mut url = join_url(&self.url, route.as_ref())?;

        // GET and DELETE requests do not support a request body
        let data_in_query_string = method == Method::GET || method == Method::DELETE;

        if data_in_query_string {
            let query = serde_qs::to_string(data)
                .map_err(|err| Error::QueryString(method.clone(), url.clone(), Box::new(err)))?;
            if !query.is_empty() {
                url.set_query(Some(&query));
            }
        }

        let mut req = self.client.request(method.clone(), url.clone());

        if !data_in_query_string {
            req = req.json(data);
        }

        req = req.headers(headers);

        let resp = req
            .send()
            .await
            .map_err(|err| Error::Request(method.clone(), url.clone(), Box::new(err)))?;

        let status = resp.status();

        #[cfg(feature = "tracing")]
        tracing::debug!("{} {} => {}", method, url, status);

        if resp.error_for_status_ref().is_ok() {
            // Successful response
            let body_text = resp
                .text()
                .await
                .map_err(|err| Error::Request(method.clone(), url.clone(), Box::new(err.into())))?;

            // If body_text is the empty string, that does not deserialize to valid json ever, at least in serde_json implementation.
            // But sometimes it's useful for Option<JsonValue>, or more generally Option<T>. to match that successfully, because some services
            // have empty body text on a success response.
            // So, if body_text is empty, we'll mask it to "null" before parsing as json, so that there is a way that
            // an empty body in a success response can be valid.
            let body_str: &str = if body_text.is_empty() {
                "null"
            } else {
                &body_text
            };

            let body: T =
                serde_json::from_str(body_str).map_err(|err| Error::Deserialization(method, url, status, if body_text.is_empty() {
                    <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}")
                    } else { err }))?;
            Ok(body)
        } else {
            // In the basic client, we do no further processing of the error response, and hand it to the caller.
            Err(Error::Response(method, url, status, Box::pin(resp)))
        }
    }
}

/// An error which occurs when using the BasicClient
///
/// This can be a serialization problem, a problem making the request, or an unsuccessful response.
/// Unsuccessful means any non 2xx response code.
#[derive(Debug, Display)]
pub enum Error {
    /// Url cannot be a base: {0}
    UrlCannotBeABase(Url),
    /// Query string encoding: {0} {1} => {2}
    QueryString(Method, Url, Box<SerdeQsError>),
    /// Reqwest: {0} {1} => {2}
    Request(Method, Url, Box<ReqwestMiddlewareError>),
    /// Error serializing json for request {0} {1} => {2}
    Serialization(Method, Url, SerdeJsonError),
    /// Error deserializing a response: {0} {1} => {2}: {3}
    Deserialization(Method, Url, StatusCode, SerdeJsonError),
    /// Error Response: {0} {1} => {2}
    Response(Method, Url, StatusCode, Pin<Box<Response>>),
}

/// When making requests, this client works by appending routes as needed to the base url of the api
/// This function is a helper to work around issues with url::join, where it is sensitive to trailing slashes
/// in the base API, but that is potentially confusing and not usually what we want.
/// https://docs.rs/url/latest/url/struct.Url.html#method.join
/// https://github.com/servo/rust-url/issues/333
/// Instead we clone the original url and append path segments using the path_segments_mut api.
/// We have to split the route on `/` for this to work correctly, otherwise the library will escape the `/` characters...
/// We modeled the splitting procedure on the code in url lib here:
/// https://docs.rs/url/2.5.0/src/url/lib.rs.html#1351
pub fn join_url(url: &Url, route: &str) -> Result<Url, Error> {
    let mut result = url.clone();
    {
        let Ok(mut path_segments_mut) = result.path_segments_mut() else {
            return Err(Error::UrlCannotBeABase(result));
        };

        // Remove the last segment if it is empty, this makes our code tolerate trailing `/`'s
        path_segments_mut.pop_if_empty();

        // Remove any leading `/` from the route, the library is going to add them.
        let route = route.strip_prefix('/').unwrap_or(route);
        for segment in route.split('/') {
            path_segments_mut.push(segment);
        }
    }
    Ok(result)
}