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());
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(_)));
}
}