use std::time::Duration;
use reqwest::{Client as HttpClient, Method, StatusCode};
use serde::Serialize;
use url::Url;
use crate::conversions::Conversions;
use crate::error::{ReroutError, Result, build_api_error};
use crate::links::Links;
use crate::project::Project;
use crate::qr::Qr;
use crate::tags::Tags;
use crate::webhooks_management::Webhooks;
pub const DEFAULT_BASE_URL: &str = "https://api.rerout.co";
pub const DEFAULT_TIMEOUT_SECONDS: u64 = 30;
#[derive(Debug, Clone, Copy)]
pub(crate) enum HttpMethod {
Get,
Post,
Patch,
Delete,
}
impl HttpMethod {
fn as_reqwest(self) -> Method {
match self {
HttpMethod::Get => Method::GET,
HttpMethod::Post => Method::POST,
HttpMethod::Patch => Method::PATCH,
HttpMethod::Delete => Method::DELETE,
}
}
}
#[derive(Debug)]
pub struct ClientBuilder {
api_key: String,
base_url: String,
http_client: Option<HttpClient>,
timeout: Duration,
user_agent: Option<String>,
}
impl ClientBuilder {
pub(crate) fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: DEFAULT_BASE_URL.to_string(),
http_client: None,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECONDS),
user_agent: None,
}
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Result<Self> {
let raw = base_url.into();
let trimmed = raw.trim_end_matches('/').to_string();
if trimmed.is_empty() {
return Err(ReroutError::Config {
code: "invalid_base_url".to_string(),
message: "base_url cannot be empty.".to_string(),
});
}
Url::parse(&trimmed).map_err(|e| ReroutError::Config {
code: "invalid_base_url".to_string(),
message: format!("base_url is not a valid URL: {e}"),
})?;
self.base_url = trimmed;
Ok(self)
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn http_client(mut self, http_client: HttpClient) -> Self {
self.http_client = Some(http_client);
self
}
#[must_use]
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
pub fn build(self) -> Result<Rerout> {
if self.api_key.trim().is_empty() {
return Err(ReroutError::Config {
code: "missing_api_key".to_string(),
message: "A project API key is required to construct Rerout.".to_string(),
});
}
let http_client = match self.http_client {
Some(client) => client,
None => {
let mut builder = HttpClient::builder().timeout(self.timeout);
if let Some(ua) = &self.user_agent {
builder = builder.user_agent(ua);
}
builder.build().map_err(|e| ReroutError::Config {
code: "http_client_build_failed".to_string(),
message: format!("Could not construct reqwest client: {e}"),
})?
}
};
Ok(Rerout {
api_key: self.api_key,
base_url: self.base_url,
http_client,
})
}
}
#[derive(Debug, Clone)]
pub struct Rerout {
api_key: String,
base_url: String,
http_client: HttpClient,
}
impl Rerout {
pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
ClientBuilder::new(api_key)
}
pub fn new(api_key: impl Into<String>) -> Result<Self> {
Self::builder(api_key).build()
}
pub fn links(&self) -> Links<'_> {
Links::new(self)
}
pub fn project(&self) -> Project<'_> {
Project::new(self)
}
pub fn qr(&self) -> Qr<'_> {
Qr::new(self)
}
pub fn webhooks(&self) -> Webhooks<'_> {
Webhooks::new(self)
}
pub fn conversions(&self) -> Conversions<'_> {
Conversions::new(self)
}
pub fn tags(&self) -> Tags<'_> {
Tags::new(self)
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub(crate) async fn request_json<T, B>(
&self,
method: HttpMethod,
path: &str,
query: Option<&[(&str, String)]>,
body: Option<&B>,
) -> Result<T>
where
T: serde::de::DeserializeOwned,
B: Serialize + ?Sized,
{
let text = self.send_request(method, path, query, body).await?;
if text.is_empty() {
return Err(ReroutError::Unexpected {
status: 200,
message: "Rerout returned an empty success body where JSON was expected."
.to_string(),
});
}
serde_json::from_str::<T>(&text).map_err(|e| ReroutError::Decode {
message: format!("Failed to decode response: {e}"),
})
}
pub(crate) async fn request_text<B>(
&self,
method: HttpMethod,
path: &str,
query: Option<&[(&str, String)]>,
body: Option<&B>,
) -> Result<String>
where
B: Serialize + ?Sized,
{
self.send_request(method, path, query, body).await
}
async fn send_request<B>(
&self,
method: HttpMethod,
path: &str,
query: Option<&[(&str, String)]>,
body: Option<&B>,
) -> Result<String>
where
B: Serialize + ?Sized,
{
let url = format!("{}{}", self.base_url, path);
let mut request = self
.http_client
.request(method.as_reqwest(), &url)
.bearer_auth(&self.api_key)
.header(reqwest::header::ACCEPT, "application/json");
if let Some(pairs) = query
&& !pairs.is_empty()
{
request = request.query(pairs);
}
if let Some(payload) = body {
request = request
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(payload);
}
let response = request.send().await.map_err(map_request_error)?;
let status = response.status();
let text = response.text().await.map_err(|e| ReroutError::Network {
message: format!("Failed to read response body: {e}"),
})?;
if status.is_success() {
Ok(text)
} else {
Err(build_api_error(status.as_u16(), &text))
}
}
}
fn map_request_error(error: reqwest::Error) -> ReroutError {
if error.is_timeout() {
return ReroutError::Timeout {
message: error.to_string(),
};
}
if let Some(status) = error.status()
&& status != StatusCode::OK
{
return ReroutError::Api {
code: crate::error::synthetic_code_for_status(status.as_u16()).to_string(),
status: status.as_u16(),
message: error.to_string(),
extra: Box::default(),
};
}
ReroutError::Network {
message: error.to_string(),
}
}