refluxer 0.2.0

Rust API wrapper for Fluxer
Documentation
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
use reqwest::{RequestBuilder, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};

use super::ratelimit::RateLimiter;
use super::routing::Route;
use crate::error::HttpError;

const DEFAULT_BASE_URL: &str = "https://api.fluxer.app/v1";
const USER_AGENT: &str = concat!("refluxer/", env!("CARGO_PKG_VERSION"));

#[derive(Debug, Clone)]
pub struct HttpClient {
    client: reqwest::Client,
    base_url: String,
    pub(crate) ratelimiter: RateLimiter,
}

impl HttpClient {
    pub fn new(token: &str) -> Result<Self, HttpError> {
        Self::builder().token(token).build()
    }

    pub fn builder() -> HttpClientBuilder {
        HttpClientBuilder::default()
    }

    pub(crate) async fn request<T: DeserializeOwned>(
        &self,
        route: Route,
        body: Option<&impl Serialize>,
    ) -> Result<T, HttpError> {
        self.fire(route, body).await
    }

    pub(crate) async fn request_empty(
        &self,
        route: Route,
        body: Option<&impl Serialize>,
    ) -> Result<(), HttpError> {
        self.fire_empty(route, body).await
    }

    pub(crate) async fn request_no_body<T: DeserializeOwned>(
        &self,
        route: Route,
    ) -> Result<T, HttpError> {
        self.fire::<(), T>(route, None).await
    }

    async fn fire_empty<B: Serialize>(
        &self,
        route: Route,
        body: Option<&B>,
    ) -> Result<(), HttpError> {
        self.send_json(route, body).await?;
        Ok(())
    }

    async fn fire<B: Serialize, T: DeserializeOwned>(
        &self,
        route: Route,
        body: Option<&B>,
    ) -> Result<T, HttpError> {
        let response = self.send_json(route, body).await?;
        let bytes = response.bytes().await?;
        let parsed: T = serde_json::from_slice(&bytes)?;
        Ok(parsed)
    }

    async fn send_json<B: Serialize>(
        &self,
        route: Route,
        body: Option<&B>,
    ) -> Result<Response, HttpError> {
        let bucket = route.bucket();
        let method = route.method();
        let url = format!("{}{}", self.base_url, route.path());
        loop {
            if !self.ratelimiter.pre_request(&bucket).await {
                return Err(HttpError::RateLimit {
                    retry_after: std::time::Duration::from_secs(1),
                    bucket: bucket.clone(),
                });
            }

            let req = self.build_json_request(method.clone(), &url, body)?;
            let response = req.send().await?;
            let headers = response.headers().clone();
            let status = response.status();

            self.ratelimiter.update(&bucket, &headers).await;

            if status == StatusCode::TOO_MANY_REQUESTS
                && let Some(retry_after) = RateLimiter::parse_retry_after(&headers)
            {
                if self.ratelimiter.auto_retry() {
                    tracing::warn!(?retry_after, "rate limited, retrying");
                    tokio::time::sleep(retry_after).await;
                    continue;
                }
                return Err(HttpError::RateLimit {
                    retry_after,
                    bucket,
                });
            }

            if !status.is_success() {
                return Err(Self::api_error(response).await);
            }

            return Ok(response);
        }
    }

    fn build_json_request<B: Serialize>(
        &self,
        method: reqwest::Method,
        url: &str,
        body: Option<&B>,
    ) -> Result<RequestBuilder, HttpError> {
        let mut req = self.client.request(method.clone(), url);
        if let Some(b) = body {
            let json_body = serde_json::to_string(b)?;
            tracing::debug!(%method, %url, body = %json_body, "HTTP request");
            req = req
                .header("content-type", "application/json")
                .body(json_body);
        } else {
            tracing::debug!(%method, %url, "HTTP request");
        }
        Ok(req)
    }

    pub(crate) async fn fire_multipart<T: DeserializeOwned>(
        &self,
        route: Route,
        form: reqwest::multipart::Form,
    ) -> Result<T, HttpError> {
        let bucket = route.bucket();
        let method = route.method();
        let url = format!("{}{}", self.base_url, route.path());

        // A multipart Form is not Clone, so retries aren't supported here.
        // If the rate-limiter signals a pre-request deny, we return immediately
        // just like `fire`. If the server responds with 429 we also do not
        // retry, because we have already consumed `form`.
        if !self.ratelimiter.pre_request(&bucket).await {
            return Err(HttpError::RateLimit {
                retry_after: std::time::Duration::from_secs(1),
                bucket,
            });
        }

        tracing::debug!(%method, %url, "HTTP multipart request");
        let response = self
            .client
            .request(method, &url)
            .multipart(form)
            .send()
            .await?;
        let headers = response.headers().clone();
        let status = response.status();

        self.ratelimiter.update(&bucket, &headers).await;

        if status == StatusCode::TOO_MANY_REQUESTS
            && let Some(retry_after) = RateLimiter::parse_retry_after(&headers)
        {
            return Err(HttpError::RateLimit {
                retry_after,
                bucket,
            });
        }

        if !status.is_success() {
            return Err(Self::api_error(response).await);
        }

        let bytes = response.bytes().await?;
        let parsed: T = serde_json::from_slice(&bytes)?;
        Ok(parsed)
    }

    async fn api_error(response: Response) -> HttpError {
        let status = response.status();
        let text = response.text().await.unwrap_or_default();
        tracing::debug!(status = status.as_u16(), %text, "API error response");
        #[derive(Deserialize)]
        struct ApiErrorBody {
            code: Option<String>,
            message: Option<String>,
        }
        let parsed: ApiErrorBody = serde_json::from_str(&text).unwrap_or(ApiErrorBody {
            code: None,
            message: None,
        });
        HttpError::Api {
            status: status.as_u16(),
            code: parsed.code.unwrap_or_else(|| status.as_u16().to_string()),
            message: parsed.message.unwrap_or(text),
        }
    }
}

#[derive(Debug)]
pub struct HttpClientBuilder {
    token: Option<String>,
    base_url: String,
    auto_retry: bool,
}

impl Default for HttpClientBuilder {
    fn default() -> Self {
        Self {
            token: None,
            base_url: DEFAULT_BASE_URL.into(),
            auto_retry: true,
        }
    }
}

impl HttpClientBuilder {
    pub fn token(mut self, token: &str) -> Self {
        self.token = Some(token.into());
        self
    }
    pub fn base_url(mut self, url: &str) -> Self {
        self.base_url = url.trim_end_matches('/').into();
        self
    }
    pub fn auto_retry(mut self, enabled: bool) -> Self {
        self.auto_retry = enabled;
        self
    }
    pub fn build(self) -> Result<HttpClient, HttpError> {
        let token = self.token.ok_or(HttpError::MissingToken)?;
        let auth_value = if token.starts_with("Bearer ") {
            token
        } else {
            format!("Bot {token}")
        };
        let mut headers = HeaderMap::new();
        headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
        let client = reqwest::Client::builder()
            .default_headers(headers)
            .user_agent(USER_AGENT)
            .build()?;
        Ok(HttpClient {
            client,
            base_url: self.base_url,
            ratelimiter: RateLimiter::new(self.auto_retry),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builder_requires_token() {
        let err = HttpClient::builder()
            .build()
            .expect_err("token is required");

        assert!(matches!(err, HttpError::MissingToken));
    }

    #[test]
    fn builder_rejects_invalid_auth_header() {
        let err = HttpClient::builder()
            .token("bad\nvalue")
            .build()
            .expect_err("invalid token should not build");

        assert!(matches!(err, HttpError::InvalidAuthHeader(_)));
    }

    #[test]
    fn new_returns_builder_error() {
        let err = HttpClient::new("bad\nvalue").expect_err("invalid token should not build");

        assert!(matches!(err, HttpError::InvalidAuthHeader(_)));
    }
}