use generated_public_routes::UNEXPOSED_ROUTES;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Client as ReqwestClient, Method};
use serde_json::Value;
use url::Url;
pub mod api;
pub mod generated_public_routes;
const COMPONENT: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'!')
.remove(b'~')
.remove(b'*')
.remove(b'\'')
.remove(b'(')
.remove(b')');
pub fn enc(value: &str) -> String {
utf8_percent_encode(value, COMPONENT).to_string()
}
pub type QueryParam = (&'static str, Option<String>);
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid url: {0}")]
Url(#[from] url::ParseError),
#[error("http client error: {0}")]
Http(#[from] reqwest::Error),
#[error("http {status}: {body}")]
Status { status: u16, body: Value },
#[error("invalid header value: {0}")]
Header(#[from] reqwest::header::InvalidHeaderValue),
#[error("response decoding failed: {0}")]
Decode(#[from] serde_json::Error),
#[error("route is intentionally not exposed by the SDK: {0} {1}")]
RouteNotExposed(String, String),
}
#[derive(Clone)]
pub struct TinyHumansClient {
http: HttpClient,
}
impl TinyHumansClient {
pub fn new(base_url: impl AsRef<str>) -> Self {
Self {
http: HttpClient::new(base_url.as_ref()),
}
}
pub fn with_token(mut self, token: Option<String>) -> Self {
self.http.token = token;
self
}
pub fn with_api_key(mut self, api_key: Option<String>) -> Self {
self.http.api_key = api_key;
self
}
pub fn raw(&self) -> &HttpClient {
&self.http
}
pub fn agent_integrations(&self) -> api::agent_integrations::AgentIntegrationsApi<'_> {
api::agent_integrations::AgentIntegrationsApi::new(&self.http)
}
pub fn announcements(&self) -> api::announcements::AnnouncementsApi<'_> {
api::announcements::AnnouncementsApi::new(&self.http)
}
pub fn auth(&self) -> api::auth::AuthApi<'_> {
api::auth::AuthApi::new(&self.http)
}
pub fn channels(&self) -> api::channels::ChannelsApi<'_> {
api::channels::ChannelsApi::new(&self.http)
}
pub fn coupons(&self) -> api::coupons::CouponsApi<'_> {
api::coupons::CouponsApi::new(&self.http)
}
pub fn feedback(&self) -> api::feedback::FeedbackApi<'_> {
api::feedback::FeedbackApi::new(&self.http)
}
pub fn health(&self) -> api::health::HealthApi<'_> {
api::health::HealthApi::new(&self.http)
}
pub fn inference(&self) -> api::inference::InferenceApi<'_> {
api::inference::InferenceApi::new(&self.http)
}
pub fn api_keys(&self) -> api::api_keys::ApiKeysApi<'_> {
api::api_keys::ApiKeysApi::new(&self.http)
}
pub fn budgets(&self) -> api::budgets::BudgetsApi<'_> {
api::budgets::BudgetsApi::new(&self.http)
}
pub fn invite(&self) -> api::invite::InviteApi<'_> {
api::invite::InviteApi::new(&self.http)
}
pub fn mascots(&self) -> api::mascots::MascotsApi<'_> {
api::mascots::MascotsApi::new(&self.http)
}
pub fn medulla(&self) -> api::medulla::MedullaApi<'_> {
api::medulla::MedullaApi::new(&self.http)
}
pub fn opencompany(&self) -> api::opencompany::OpenCompanyApi<'_> {
api::opencompany::OpenCompanyApi::new(&self.http)
}
pub fn orchestration(&self) -> api::orchestration::OrchestrationApi<'_> {
api::orchestration::OrchestrationApi::new(&self.http)
}
pub fn payments(&self) -> api::payments::PaymentsApi<'_> {
api::payments::PaymentsApi::new(&self.http)
}
pub fn redirect(&self) -> api::redirect::RedirectApi<'_> {
api::redirect::RedirectApi::new(&self.http)
}
pub fn referral(&self) -> api::referral::ReferralApi<'_> {
api::referral::ReferralApi::new(&self.http)
}
pub fn rewards(&self) -> api::rewards::RewardsApi<'_> {
api::rewards::RewardsApi::new(&self.http)
}
pub fn teams(&self) -> api::teams::TeamsApi<'_> {
api::teams::TeamsApi::new(&self.http)
}
}
#[derive(Clone)]
pub struct HttpClient {
base_url: String,
token: Option<String>,
api_key: Option<String>,
client: ReqwestClient,
}
impl HttpClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
token: None,
api_key: None,
client: ReqwestClient::new(),
}
}
pub async fn send(
&self,
method: Method,
path: &str,
query: &[QueryParam],
body: Option<&Value>,
unwrap: bool,
) -> Result<Value, Error> {
reject_unexposed_route(&method, path)?;
let url = self.url(path, query)?;
let mut request = self.client.request(method, url).headers(self.headers()?);
if let Some(body) = body {
request = request.json(body);
}
let response = request.send().await?;
let status = response.status();
let text = response.text().await?;
let value = if text.is_empty() {
Value::Null
} else {
serde_json::from_str(&text).unwrap_or(Value::String(text))
};
if !status.is_success() {
return Err(Error::Status {
status: status.as_u16(),
body: value,
});
}
Ok(if unwrap {
unwrap_envelope(value)
} else {
value
})
}
pub async fn send_typed<T: serde::de::DeserializeOwned>(
&self,
method: Method,
path: &str,
query: &[QueryParam],
body: Option<&Value>,
unwrap: bool,
) -> Result<T, Error> {
let value = self.send(method, path, query, body, unwrap).await?;
Ok(serde_json::from_value(value)?)
}
pub async fn get(&self, path: &str) -> Result<Value, Error> {
self.send(Method::GET, path, &[], None, true).await
}
pub async fn post(&self, path: &str, body: &Value) -> Result<Value, Error> {
self.send(Method::POST, path, &[], Some(body), true).await
}
pub async fn post_multipart(
&self,
path: &str,
form: reqwest::multipart::Form,
) -> Result<Value, Error> {
reject_unexposed_route(&Method::POST, path)?;
let url = self.url(path, &[])?;
let response = self
.client
.post(url)
.headers(self.headers()?)
.multipart(form)
.send()
.await?;
let status = response.status();
let text = response.text().await?;
let value = if text.is_empty() {
Value::Null
} else {
serde_json::from_str(&text).unwrap_or(Value::String(text))
};
if !status.is_success() {
return Err(Error::Status {
status: status.as_u16(),
body: value,
});
}
Ok(unwrap_envelope(value))
}
pub async fn send_bytes(&self, method: Method, path: &str) -> Result<Vec<u8>, Error> {
reject_unexposed_route(&method, path)?;
let response = self
.client
.request(method, self.url(path, &[])?)
.headers(self.headers()?)
.send()
.await?;
let status = response.status();
let bytes = response.bytes().await?;
if !status.is_success() {
let body = serde_json::from_slice(&bytes)
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()));
return Err(Error::Status {
status: status.as_u16(),
body,
});
}
Ok(bytes.to_vec())
}
fn url(&self, path: &str, query: &[QueryParam]) -> Result<Url, Error> {
let normalized = if path.starts_with('/') {
path.to_owned()
} else {
format!("/{path}")
};
let mut url = Url::parse(&format!("{}{}", self.base_url, normalized))?;
let pairs: Vec<(&str, String)> = query
.iter()
.filter_map(|(k, v)| v.as_ref().map(|v| (*k, v.clone())))
.collect();
if !pairs.is_empty() {
url.query_pairs_mut().extend_pairs(pairs);
}
Ok(url)
}
fn headers(&self) -> Result<HeaderMap, Error> {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert("x-sdk-client", HeaderValue::from_static("tinyhumans-rust"));
if let Some(token) = &self.token {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}"))?,
);
}
if let Some(api_key) = &self.api_key {
headers.insert("x-api-key", HeaderValue::from_str(api_key)?);
}
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(headers)
}
}
fn unwrap_envelope(body: Value) -> Value {
match body {
Value::Object(mut map) if map.get("success") == Some(&Value::Bool(true)) => {
map.remove("data").unwrap_or(Value::Object(map))
}
other => other,
}
}
fn reject_unexposed_route(method: &Method, path: &str) -> Result<(), Error> {
let request_segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
let blocked = UNEXPOSED_ROUTES.iter().any(|(blocked_method, template)| {
if *blocked_method != method.as_str() {
return false;
}
let template_segments = template.trim_matches('/').split('/').collect::<Vec<_>>();
template_segments.len() == request_segments.len()
&& template_segments
.iter()
.zip(&request_segments)
.all(|(expected, actual)| {
(expected.starts_with('{') && expected.ends_with('}')) || expected == actual
})
});
if blocked {
Err(Error::RouteNotExposed(
method.as_str().to_owned(),
path.to_owned(),
))
} else {
Ok(())
}
}
#[cfg(test)]
mod exclusion_tests {
use super::*;
#[test]
fn every_admin_and_webhook_route_is_rejected_by_the_raw_transport_gate() {
assert_eq!(UNEXPOSED_ROUTES.len(), 53);
for (method, template) in UNEXPOSED_ROUTES {
let concrete_path = template
.split('/')
.map(|segment| {
if segment.starts_with('{') && segment.ends_with('}') {
"example"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/");
let method = Method::from_bytes(method.as_bytes()).unwrap();
assert!(
matches!(
reject_unexposed_route(&method, &concrete_path),
Err(Error::RouteNotExposed(_, _))
),
"{} {template} was not blocked",
method.as_str()
);
}
}
}