use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use url::Url;
use crate::errors::HeyoError;
const DEFAULT_BASE_URL: &str = "https://server.heyo.computer";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Default, Clone)]
pub struct HeyoClientOptions {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub timeout: Option<Duration>,
}
#[derive(Debug, Default, Clone)]
pub struct RequestOptions {
pub timeout: Option<Duration>,
pub query: Vec<(String, String)>,
}
#[derive(Clone)]
pub struct HeyoClient {
inner: Arc<Inner>,
}
struct Inner {
api_key: String,
base_url: String,
http: reqwest::Client,
default_timeout: Duration,
}
impl HeyoClient {
pub fn new(opts: HeyoClientOptions) -> Result<Self, HeyoError> {
let api_key = opts
.api_key
.or_else(|| std::env::var("HEYO_API_KEY").ok())
.ok_or(HeyoError::Authentication)?;
let base_url = opts
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
.trim_end_matches('/')
.to_string();
let http = reqwest::Client::builder()
.build()
.map_err(|e| HeyoError::Connection(e.to_string()))?;
Ok(Self {
inner: Arc::new(Inner {
api_key,
base_url,
http,
default_timeout: opts.timeout.unwrap_or(DEFAULT_TIMEOUT),
}),
})
}
pub fn base_url(&self) -> &str {
&self.inner.base_url
}
#[allow(dead_code)]
pub(crate) fn api_key(&self) -> &str {
&self.inner.api_key
}
pub async fn request<T: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: Option<&(impl Serialize + ?Sized)>,
opts: RequestOptions,
) -> Result<T, HeyoError> {
let bytes = self.request_bytes(method, path, body, opts).await?;
if bytes.is_empty() {
return serde_json::from_slice::<T>(b"null").map_err(|e| {
HeyoError::api(0, format!("empty response body could not be parsed: {}", e))
});
}
serde_json::from_slice::<T>(&bytes)
.map_err(|e| HeyoError::api(0, format!("invalid JSON response: {}", e)))
}
pub async fn request_bytes(
&self,
method: Method,
path: &str,
body: Option<&(impl Serialize + ?Sized)>,
opts: RequestOptions,
) -> Result<Vec<u8>, HeyoError> {
let response = self.raw_request(method, path, body, opts).await?;
self.consume_response(response, path).await
}
pub async fn raw_request(
&self,
method: Method,
path: &str,
body: Option<&(impl Serialize + ?Sized)>,
opts: RequestOptions,
) -> Result<Response, HeyoError> {
let url = self.build_url(path, &opts.query)?;
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
let auth = format!("Bearer {}", self.inner.api_key);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth)
.map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
);
let mut builder = self
.inner
.http
.request(method, url)
.headers(headers)
.timeout(opts.timeout.unwrap_or(self.inner.default_timeout));
if let Some(body) = body {
builder = builder
.header(CONTENT_TYPE, "application/json")
.json(body);
}
builder
.send()
.await
.map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
}
pub async fn put_bytes(
&self,
path: &str,
body: Vec<u8>,
content_type: &str,
opts: RequestOptions,
) -> Result<Response, HeyoError> {
let url = self.build_url(path, &opts.query)?;
let mut headers = HeaderMap::new();
let auth = format!("Bearer {}", self.inner.api_key);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth)
.map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
);
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(content_type)
.map_err(|e| HeyoError::api(0, format!("invalid content-type: {}", e)))?,
);
self.inner
.http
.request(Method::PUT, url)
.headers(headers)
.timeout(opts.timeout.unwrap_or(self.inner.default_timeout))
.body(body)
.send()
.await
.map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
}
pub(crate) fn ws_url(&self, path: &str) -> Result<String, HeyoError> {
let http_url = self.build_url(path, &[])?;
let mut parsed = Url::parse(&http_url)
.map_err(|e| HeyoError::Connection(format!("bad URL {}: {}", http_url, e)))?;
let scheme = match parsed.scheme() {
"https" => "wss",
"http" => "ws",
other => return Err(HeyoError::Connection(format!("unsupported scheme {}", other))),
};
parsed
.set_scheme(scheme)
.map_err(|_| HeyoError::Connection("could not swap to ws scheme".into()))?;
Ok(parsed.to_string())
}
pub(crate) fn ws_authorization(&self) -> String {
format!("Bearer {}", self.inner.api_key)
}
fn build_url(&self, path: &str, query: &[(String, String)]) -> Result<String, HeyoError> {
let clean = if path.starts_with('/') {
path.to_string()
} else {
format!("/{}", path)
};
let mut url = Url::parse(&format!("{}{}", self.inner.base_url, clean))
.map_err(|e| HeyoError::api(0, format!("bad URL {}{}: {}", self.inner.base_url, clean, e)))?;
if !query.is_empty() {
let mut pairs = url.query_pairs_mut();
for (k, v) in query {
pairs.append_pair(k, v);
}
}
Ok(url.to_string())
}
async fn consume_response(
&self,
response: Response,
path: &str,
) -> Result<Vec<u8>, HeyoError> {
let status = response.status();
let bytes = response
.bytes()
.await
.map_err(|e| HeyoError::api(0, format!("read body for {}: {}", path, e)))?;
if status.is_success() {
if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
return Ok(Vec::new());
}
return Ok(bytes.to_vec());
}
let mut message = format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
let mut parsed_body: Option<serde_json::Value> = None;
if !bytes.is_empty() {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if let Some(m) = v.get("message").and_then(|x| x.as_str()) {
message = m.to_string();
} else if let Some(e) = v.get("error").and_then(|x| x.as_str()) {
message = e.to_string();
}
parsed_body = Some(v);
} else if let Ok(text) = std::str::from_utf8(&bytes) {
message = text.to_string();
}
}
let with_path = format!("{} (calling {})", message, path);
Err(match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => HeyoError::Authentication,
StatusCode::NOT_FOUND => HeyoError::NotFound(with_path),
StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
HeyoError::InvalidArgument(with_path)
}
_ => HeyoError::api_with_body(status.as_u16(), with_path, parsed_body),
})
}
}
impl std::fmt::Debug for HeyoClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HeyoClient")
.field("base_url", &self.inner.base_url)
.field("default_timeout", &self.inner.default_timeout)
.finish_non_exhaustive()
}
}