use reqwest::{Client as AsyncClient, blocking::Client as BlockingClient};
use serde::{Serialize, de::DeserializeOwned};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use crate::socket::SerializationKey;
use crate::serialization::SerializationError;
pub use toolkit_zero_macros::request;
fn build_url(base: &str, endpoint: &str) -> String {
let ep = endpoint.trim_start_matches('/');
if ep.is_empty() {
base.trim_end_matches('/').to_owned()
} else {
format!("{}/{}", base.trim_end_matches('/'), ep)
}
}
#[derive(Clone, Copy, Debug)]
enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
Head,
Options,
}
impl HttpMethod {
fn apply_async(&self, client: &AsyncClient, url: &str) -> reqwest::RequestBuilder {
match self {
HttpMethod::Get => client.get(url),
HttpMethod::Post => client.post(url),
HttpMethod::Put => client.put(url),
HttpMethod::Delete => client.delete(url),
HttpMethod::Patch => client.patch(url),
HttpMethod::Head => client.head(url),
HttpMethod::Options => client.request(reqwest::Method::OPTIONS, url),
}
}
fn apply_sync(&self, client: &BlockingClient, url: &str) -> reqwest::blocking::RequestBuilder {
match self {
HttpMethod::Get => client.get(url),
HttpMethod::Post => client.post(url),
HttpMethod::Put => client.put(url),
HttpMethod::Delete => client.delete(url),
HttpMethod::Patch => client.patch(url),
HttpMethod::Head => client.head(url),
HttpMethod::Options => client.request(reqwest::Method::OPTIONS, url),
}
}
}
#[derive(Clone)]
pub enum Target {
Localhost(u16),
Remote(String),
}
#[derive(Clone)]
pub struct Client {
target: Target,
async_client: Option<AsyncClient>,
sync_client: Option<BlockingClient>,
}
impl Client {
pub fn new_async(target: Target) -> Self {
log::debug!("Creating async-only client");
Self { target, async_client: Some(AsyncClient::new()), sync_client: None }
}
pub fn new_sync(target: Target) -> Self {
log::debug!("Creating sync-only client");
Self { target, async_client: None, sync_client: Some(BlockingClient::new()) }
}
pub fn new(target: Target) -> Self {
if tokio::runtime::Handle::try_current().is_ok() {
panic!(
"Client::new() called inside an async context (tokio runtime detected). \
BlockingClient cannot be created inside an existing runtime.\n\
→ Use Client::new_async(target) if you only need .send() (async).\n\
→ Use Client::new_sync(target) called before entering any async runtime if you only need .send_sync()."
);
}
log::debug!("Creating dual async+sync client");
Self {
target,
async_client: Some(AsyncClient::new()),
sync_client: Some(BlockingClient::new()),
}
}
fn async_client(&self) -> &AsyncClient {
self.async_client.as_ref()
.expect("Client was created with new_sync() — call new_async() or new() to use async sends")
}
fn sync_client(&self) -> &BlockingClient {
self.sync_client.as_ref()
.expect("Client was created with new_async() — call new_sync() or new() to use sync sends")
}
pub fn base_url(&self) -> String {
match &self.target {
Target::Localhost(port) => format!("http://localhost:{}", port),
Target::Remote(url) => url.clone(),
}
}
fn builder(&self, method: HttpMethod, endpoint: impl Into<String>) -> RequestBuilder<'_> {
RequestBuilder::new(self, method, endpoint)
}
pub fn get(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
self.builder(HttpMethod::Get, endpoint)
}
pub fn post(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
self.builder(HttpMethod::Post, endpoint)
}
pub fn put(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
self.builder(HttpMethod::Put, endpoint)
}
pub fn delete(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
self.builder(HttpMethod::Delete, endpoint)
}
pub fn patch(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
self.builder(HttpMethod::Patch, endpoint)
}
pub fn head(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
self.builder(HttpMethod::Head, endpoint)
}
pub fn options(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
self.builder(HttpMethod::Options, endpoint)
}
}
pub struct RequestBuilder<'a> {
client: &'a Client,
method: HttpMethod,
endpoint: String,
}
impl<'a> RequestBuilder<'a> {
fn new(client: &'a Client, method: HttpMethod, endpoint: impl Into<String>) -> Self {
let endpoint = endpoint.into();
log::debug!("Building {:?} request for endpoint '{}'", method, endpoint);
Self { client, method, endpoint }
}
pub fn json<T: Serialize>(self, body: T) -> JsonRequestBuilder<'a, T> {
log::trace!("Attaching JSON body to {:?} request for '{}'", self.method, self.endpoint);
JsonRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, body }
}
pub fn query<T: Serialize>(self, params: T) -> QueryRequestBuilder<'a, T> {
log::trace!("Attaching query params to {:?} request for '{}'", self.method, self.endpoint);
QueryRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, params }
}
pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending async {:?} to '{}'", self.method, url);
let resp = self.method.apply_async(&self.client.async_client(), &url)
.send().await?;
log::debug!("Response status: {}", resp.status());
resp.error_for_status()?.json::<R>().await
}
pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending sync {:?} to '{}'", self.method, url);
let resp = self.method.apply_sync(&self.client.sync_client(), &url)
.send()?;
log::debug!("Response status: {}", resp.status());
resp.error_for_status()?.json::<R>()
}
pub fn encryption<T: bincode::Encode>(self, body: T, key: SerializationKey) -> EncryptedBodyRequestBuilder<'a, T> {
log::trace!("Attaching encrypted body to {:?} request for '{}'", self.method, self.endpoint);
EncryptedBodyRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, body, key }
}
pub fn encrypted_query<T: bincode::Encode>(self, params: T, key: SerializationKey) -> EncryptedQueryRequestBuilder<'a, T> {
log::trace!("Attaching encrypted query to {:?} request for '{}'", self.method, self.endpoint);
EncryptedQueryRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, params, key }
}
}
pub struct JsonRequestBuilder<'a, T> {
client: &'a Client,
method: HttpMethod,
endpoint: String,
body: T,
}
impl<'a, T: Serialize> JsonRequestBuilder<'a, T> {
pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending async {:?} with JSON body to '{}'", self.method, url);
let resp = self.method.apply_async(&self.client.async_client(), &url)
.json(&self.body)
.send().await?;
log::debug!("Response status: {}", resp.status());
resp.error_for_status()?.json::<R>().await
}
pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending sync {:?} with JSON body to '{}'", self.method, url);
let resp = self.method.apply_sync(&self.client.sync_client(), &url)
.json(&self.body)
.send()?;
log::debug!("Response status: {}", resp.status());
resp.error_for_status()?.json::<R>()
}
}
pub struct QueryRequestBuilder<'a, T> {
client: &'a Client,
method: HttpMethod,
endpoint: String,
params: T,
}
impl<'a, T: Serialize> QueryRequestBuilder<'a, T> {
pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending async {:?} with query params to '{}'", self.method, url);
let resp = self.method.apply_async(&self.client.async_client(), &url)
.query(&self.params)
.send().await?;
log::debug!("Response status: {}", resp.status());
resp.error_for_status()?.json::<R>().await
}
pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending sync {:?} with query params to '{}'", self.method, url);
let resp = self.method.apply_sync(&self.client.sync_client(), &url)
.query(&self.params)
.send()?;
log::debug!("Response status: {}", resp.status());
resp.error_for_status()?.json::<R>()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ClientError {
Transport(reqwest::Error),
Serialization(SerializationError),
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Transport(e) => write!(f, "transport error: {e}"),
Self::Serialization(e) => write!(f, "serialization error: {e}"),
}
}
}
impl std::error::Error for ClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Transport(e) => Some(e),
Self::Serialization(e) => Some(e),
}
}
}
impl From<reqwest::Error> for ClientError {
fn from(e: reqwest::Error) -> Self { Self::Transport(e) }
}
impl From<SerializationError> for ClientError {
fn from(e: SerializationError) -> Self { Self::Serialization(e) }
}
pub struct EncryptedBodyRequestBuilder<'a, T> {
client: &'a Client,
method: HttpMethod,
endpoint: String,
body: T,
key: SerializationKey,
}
impl<'a, T: bincode::Encode> EncryptedBodyRequestBuilder<'a, T> {
pub async fn send<R>(self) -> Result<R, ClientError>
where
R: bincode::Decode<()>,
{
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending async {:?} with encrypted body to '{}'", self.method, url);
let sealed = crate::serialization::seal(&self.body, self.key.veil_key())?;
let resp = self.method.apply_async(&self.client.async_client(), &url)
.header("content-type", "application/octet-stream")
.body(sealed)
.send().await?;
log::debug!("Response status: {}", resp.status());
let bytes = resp.bytes().await?;
Ok(crate::serialization::open::<R, _>(&bytes, self.key.veil_key())?)
}
pub fn send_sync<R>(self) -> Result<R, ClientError>
where
R: bincode::Decode<()>,
{
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending sync {:?} with encrypted body to '{}'", self.method, url);
let sealed = crate::serialization::seal(&self.body, self.key.veil_key())?;
let resp = self.method.apply_sync(&self.client.sync_client(), &url)
.header("content-type", "application/octet-stream")
.body(sealed)
.send()?;
log::debug!("Response status: {}", resp.status());
let bytes = resp.bytes()?;
Ok(crate::serialization::open::<R, _>(&bytes, self.key.veil_key())?)
}
}
pub struct EncryptedQueryRequestBuilder<'a, T> {
client: &'a Client,
method: HttpMethod,
endpoint: String,
params: T,
key: SerializationKey,
}
impl<'a, T: bincode::Encode> EncryptedQueryRequestBuilder<'a, T> {
pub async fn send<R>(self) -> Result<R, ClientError>
where
R: bincode::Decode<()>,
{
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending async {:?} with encrypted query to '{}'", self.method, url);
let sealed = crate::serialization::seal(&self.params, self.key.veil_key())?;
let b64 = URL_SAFE_NO_PAD.encode(&sealed);
let resp = self.method.apply_async(&self.client.async_client(), &url)
.query(&[("data", &b64)])
.send().await?;
log::debug!("Response status: {}", resp.status());
let bytes = resp.bytes().await?;
Ok(crate::serialization::open::<R, _>(&bytes, self.key.veil_key())?)
}
pub fn send_sync<R>(self) -> Result<R, ClientError>
where
R: bincode::Decode<()>,
{
let url = build_url(&self.client.base_url(), &self.endpoint);
log::info!("Sending sync {:?} with encrypted query to '{}'", self.method, url);
let sealed = crate::serialization::seal(&self.params, self.key.veil_key())?;
let b64 = URL_SAFE_NO_PAD.encode(&sealed);
let resp = self.method.apply_sync(&self.client.sync_client(), &url)
.query(&[("data", &b64)])
.send()?;
log::debug!("Response status: {}", resp.status());
let bytes = resp.bytes()?;
Ok(crate::serialization::open::<R, _>(&bytes, self.key.veil_key())?)
}
}
pub struct ClientBuilder {
target: Target,
timeout: Option<std::time::Duration>,
}
impl ClientBuilder {
pub fn new(target: Target) -> Self {
Self { target, timeout: None }
}
pub fn timeout(mut self, duration: std::time::Duration) -> Self {
self.timeout = Some(duration);
self
}
pub fn build_async(self) -> Result<Client, reqwest::Error> {
log::debug!("Building async-only client (timeout={:?})", self.timeout);
let mut builder = AsyncClient::builder();
if let Some(t) = self.timeout {
builder = builder.timeout(t);
}
Ok(Client {
target: self.target,
async_client: Some(builder.build()?),
sync_client: None,
})
}
pub fn build_sync(self) -> Result<Client, reqwest::Error> {
log::debug!("Building sync-only client (timeout={:?})", self.timeout);
let mut builder = BlockingClient::builder();
if let Some(t) = self.timeout {
builder = builder.timeout(t);
}
Ok(Client {
target: self.target,
async_client: None,
sync_client: Some(builder.build()?),
})
}
pub fn build(self) -> Result<Client, reqwest::Error> {
if tokio::runtime::Handle::try_current().is_ok() {
panic!(
"ClientBuilder::build() called inside an async context. \
Use ClientBuilder::build_async() for async-only clients."
);
}
log::debug!("Building dual async+sync client (timeout={:?})", self.timeout);
let mut async_builder = AsyncClient::builder();
let mut sync_builder = BlockingClient::builder();
if let Some(t) = self.timeout {
async_builder = async_builder.timeout(t);
sync_builder = sync_builder.timeout(t);
}
Ok(Client {
target: self.target,
async_client: Some(async_builder.build()?),
sync_client: Some(sync_builder.build()?),
})
}
}