hevy 0.1.0

An async Rust client library for the Hevy public API (https://api.hevyapp.com/docs/)
Documentation
use serde::Serialize;
use serde::de::DeserializeOwned;

use crate::error::{HevyError, Result};

/// The default base URL for the Hevy API.
pub const DEFAULT_BASE_URL: &str = "https://api.hevyapp.com";

/// An async client for the [Hevy API](https://api.hevyapp.com/docs/).
///
/// A `HevyClient` holds an internal [`reqwest::Client`] (which is cheap to clone
/// and pools connections internally), your Hevy API key, and the base URL to send
/// requests to. Clone it freely if you need to share it across tasks.
///
/// Obtain an API key from <https://hevy.com/settings?developer> (requires Hevy Pro).
///
/// # Example
///
/// ```no_run
/// use hevy::HevyClient;
///
/// # async fn run() -> hevy::Result<()> {
/// let client = HevyClient::new("YOUR_API_KEY");
/// let page = client.list_workouts(None, None).await?;
/// println!("Fetched {} workouts", page.workouts.len());
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct HevyClient {
    pub(crate) http: reqwest::Client,
    pub(crate) api_key: String,
    pub(crate) base_url: String,
}

impl HevyClient {
    /// Creates a new client using the default Hevy API base URL.
    pub fn new(api_key: impl Into<String>) -> Self {
        Self::with_base_url(api_key, DEFAULT_BASE_URL)
    }

    /// Creates a new client pointed at a custom base URL.
    ///
    /// This is primarily useful for testing against a mock server.
    pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
        Self {
            http: reqwest::Client::new(),
            api_key: api_key.into(),
            base_url: base_url.into().trim_end_matches('/').to_string(),
        }
    }

    /// Creates a new client using a pre-configured [`reqwest::Client`].
    ///
    /// Useful if you need custom timeouts, proxies, or middleware.
    pub fn with_http_client(
        http: reqwest::Client,
        api_key: impl Into<String>,
        base_url: impl Into<String>,
    ) -> Self {
        Self {
            http,
            api_key: api_key.into(),
            base_url: base_url.into().trim_end_matches('/').to_string(),
        }
    }

    fn url(&self, path: &str) -> String {
        format!("{}{}", self.base_url, path)
    }

    fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
        builder.header("api-key", &self.api_key)
    }

    /// Performs a `GET` request against `path` with the given query parameters
    /// and deserializes the JSON response body into `T`.
    pub(crate) async fn get<T: DeserializeOwned>(
        &self,
        path: &str,
        query: &[(&str, Option<String>)],
    ) -> Result<T> {
        let params: Vec<(&str, String)> = query
            .iter()
            .filter_map(|(k, v)| v.clone().map(|v| (*k, v)))
            .collect();

        let request = self.authed(self.http.get(self.url(path))).query(&params);
        let response = request.send().await?;
        Self::handle_response(response).await
    }

    /// Performs a `POST` request with a JSON body and deserializes the response.
    pub(crate) async fn post<B: Serialize + ?Sized, T: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T> {
        let request = self.authed(self.http.post(self.url(path))).json(body);
        let response = request.send().await?;
        Self::handle_response(response).await
    }

    /// Performs a `PUT` request with a JSON body and deserializes the response.
    pub(crate) async fn put<B: Serialize + ?Sized, T: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T> {
        let request = self.authed(self.http.put(self.url(path))).json(body);
        let response = request.send().await?;
        Self::handle_response(response).await
    }

    /// Performs a `POST` request with a JSON body, discarding the response body.
    ///
    /// Used for endpoints that don't document a response schema on success.
    pub(crate) async fn post_no_content<B: Serialize + ?Sized>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<()> {
        let request = self.authed(self.http.post(self.url(path))).json(body);
        let response = request.send().await?;
        Self::handle_empty_response(response).await
    }

    /// Performs a `PUT` request with a JSON body, discarding the response body.
    pub(crate) async fn put_no_content<B: Serialize + ?Sized>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<()> {
        let request = self.authed(self.http.put(self.url(path))).json(body);
        let response = request.send().await?;
        Self::handle_empty_response(response).await
    }

    async fn handle_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
        let status = response.status();
        let bytes = response.bytes().await?;

        if !status.is_success() {
            return Err(Self::api_error(status.as_u16(), &bytes));
        }

        serde_json::from_slice(&bytes).map_err(HevyError::Decode)
    }

    async fn handle_empty_response(response: reqwest::Response) -> Result<()> {
        let status = response.status();
        if !status.is_success() {
            let bytes = response.bytes().await?;
            return Err(Self::api_error(status.as_u16(), &bytes));
        }
        Ok(())
    }

    fn api_error(status: u16, bytes: &[u8]) -> HevyError {
        let message = serde_json::from_slice::<serde_json::Value>(bytes)
            .ok()
            .and_then(|v| {
                v.get("error")
                    .and_then(|e| e.as_str())
                    .map(|s| s.to_string())
            })
            .unwrap_or_else(|| String::from_utf8_lossy(bytes).trim().to_string());

        HevyError::Api { status, message }
    }
}