use std::sync::Arc;
use std::time::Duration;
use reqwest::Method;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::error::Error;
use crate::http::{Auth, RequestSpec, Transport};
use crate::resources::{
auth::AuthResource, emoji::Emoji, links::Links, public::Public, stats::Stats,
};
pub const DEFAULT_BASE_URL: &str = "https://spoo.me";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_MAX_RETRIES: u32 = 2;
#[derive(Clone)]
pub struct Client {
pub(crate) transport: Arc<Transport>,
pub(crate) emoji_cache: Arc<std::sync::Mutex<Option<crate::resources::emoji::CachedSet>>>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.transport.base_url)
.finish_non_exhaustive()
}
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Client {
Client::builder().api_key(api_key).build_unchecked()
}
pub fn anonymous() -> Client {
Client::builder().build_unchecked()
}
pub fn from_env() -> Result<Client, Error> {
match std::env::var("SPOO_API_KEY") {
Ok(key) if !key.is_empty() => Ok(Client::new(key)),
_ => Err(Error::Config(
"SPOO_API_KEY is not set; pass a key to Client::new instead".into(),
)),
}
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn links(&self) -> Links {
Links {
client: self.clone(),
}
}
pub fn stats(&self) -> Stats {
Stats {
client: self.clone(),
}
}
pub fn public(&self) -> Public {
Public {
client: self.clone(),
}
}
pub fn emoji(&self) -> Emoji {
Emoji {
client: self.clone(),
}
}
pub fn auth(&self) -> AuthResource {
AuthResource {
client: self.clone(),
}
}
#[cfg(feature = "oauth")]
pub fn oauth(&self) -> crate::oauth::OAuth {
crate::oauth::OAuth {
client: self.clone(),
}
}
pub async fn get<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, Error> {
let mut spec = RequestSpec::new(Method::GET, path);
for (key, value) in query {
spec.query.push(((*key).to_owned(), (*value).to_owned()));
}
self.transport.execute(spec).await
}
pub async fn post<T: DeserializeOwned>(
&self,
path: &str,
body: &impl Serialize,
) -> Result<T, Error> {
let spec = RequestSpec::new(Method::POST, path).json(body)?;
self.transport.execute(spec).await
}
pub async fn patch<T: DeserializeOwned>(
&self,
path: &str,
body: &impl Serialize,
) -> Result<T, Error> {
let spec = RequestSpec::new(Method::PATCH, path).json(body)?;
self.transport.execute(spec).await
}
pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
let spec = RequestSpec::new(Method::DELETE, path);
self.transport.execute(spec).await
}
}
#[derive(Default)]
pub struct ClientBuilder {
api_key: Option<String>,
#[cfg(feature = "oauth")]
session: Option<Arc<crate::oauth::Session>>,
base_url: Option<String>,
http_client: Option<reqwest::Client>,
max_retries: Option<u32>,
timeout: Option<Duration>,
client_tag: Option<String>,
}
impl ClientBuilder {
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
#[cfg(feature = "oauth")]
pub fn session(mut self, session: Arc<crate::oauth::Session>) -> Self {
self.session = Some(session);
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn max_retries(mut self, retries: u32) -> Self {
self.max_retries = Some(retries);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn client_tag(mut self, tag: impl Into<String>) -> Self {
self.client_tag = Some(tag.into());
self
}
pub fn build(self) -> Result<Client, Error> {
if let Some(url) = &self.base_url {
let parsed = reqwest::Url::parse(url)
.map_err(|e| Error::Config(format!("base_url {url:?} is not a valid URL: {e}")))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(Error::Config(format!(
"base_url must use http or https, got {url:?}"
)));
}
if parsed.host_str().is_none() {
return Err(Error::Config(format!("base_url {url:?} has no host")));
}
}
Ok(self.build_unchecked())
}
fn build_unchecked(self) -> Client {
let auth = {
#[cfg(feature = "oauth")]
if let Some(session) = self.session {
Auth::Session(session)
} else if let Some(key) = self.api_key {
Auth::ApiKey(key)
} else {
Auth::None
}
#[cfg(not(feature = "oauth"))]
if let Some(key) = self.api_key {
Auth::ApiKey(key)
} else {
Auth::None
}
};
let base_url = self
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_owned())
.trim_end_matches('/')
.to_owned();
let transport = Transport {
injected_http: self.http_client,
lazy_http: std::sync::OnceLock::new(),
base_url,
auth,
client_tag: self
.client_tag
.unwrap_or_else(|| format!("sdk-rust/{}", env!("CARGO_PKG_VERSION"))),
max_retries: self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
};
Client {
transport: Arc::new(transport),
emoji_cache: Arc::new(std::sync::Mutex::new(None)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_is_send_sync_clone() {
fn assert_traits<T: Send + Sync + Clone>() {}
assert_traits::<Client>();
}
#[test]
fn builder_rejects_bad_base_url() {
assert!(Client::builder().base_url("spoo.me").build().is_err());
assert!(Client::builder().base_url("http://").build().is_err());
assert!(Client::builder().base_url("https://?").build().is_err());
assert!(Client::builder().base_url("ftp://spoo.me").build().is_err());
assert!(
Client::builder()
.base_url("https://spoo.me/")
.build()
.is_ok()
);
}
}