use std::{
sync::{Arc, Once},
time::Duration,
};
use reqwest_middleware::ClientBuilder as MiddlewareBuilder;
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
use url::Url;
use crate::{
auth::{SharedTokenProvider, StaticApiKey},
error::SdkError,
interceptor::{Interceptor, SharedInterceptor, TracingInterceptor},
resources,
retry::RetryPolicy,
transport::Transport,
};
static RUSTLS_PROVIDER_INIT: Once = Once::new();
fn ensure_rustls_provider() {
RUSTLS_PROVIDER_INIT.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub base_url: Url,
pub user_agent: String,
pub default_timeout: Duration,
pub retry: RetryPolicy,
}
#[derive(Debug)]
pub struct ClientBuilder {
token_provider: Option<SharedTokenProvider>,
base_url: Option<Url>,
user_agent: Option<String>,
default_timeout: Duration,
retry: RetryPolicy,
interceptors: Vec<SharedInterceptor>,
underlying: Option<reqwest::Client>,
}
impl Default for ClientBuilder {
fn default() -> Self {
Self {
token_provider: None,
base_url: None,
user_agent: None,
default_timeout: Duration::from_secs(30),
retry: RetryPolicy::default(),
interceptors: vec![Arc::new(TracingInterceptor) as SharedInterceptor],
underlying: None,
}
}
}
impl ClientBuilder {
#[must_use]
pub fn api_key(mut self, token: impl Into<String>) -> Self {
match StaticApiKey::from_raw(token) {
Ok(provider) => {
if self.base_url.is_none() {
self.base_url = Some(provider.api_key().base_url().clone());
}
self.token_provider = Some(Arc::new(provider));
}
Err(err) => {
self.token_provider = Some(Arc::new(FailingProvider(err.to_string())));
}
}
self
}
#[must_use]
pub fn token_provider(mut self, provider: SharedTokenProvider) -> Self {
self.token_provider = Some(provider);
self
}
pub fn base_url(mut self, url: impl AsRef<str>) -> Result<Self, SdkError> {
let parsed = Url::parse(url.as_ref())
.map_err(|e| SdkError::Config(format!("invalid base_url: {e}")))?;
self.base_url = Some(parsed);
Ok(self)
}
#[must_use]
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
#[must_use]
pub fn default_timeout(mut self, timeout: Duration) -> Self {
self.default_timeout = timeout;
self
}
#[must_use]
pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry = policy;
self
}
#[must_use]
pub fn interceptor(mut self, interceptor: impl Interceptor + 'static) -> Self {
self.interceptors.push(Arc::new(interceptor));
self
}
#[must_use]
pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
self.underlying = Some(client);
self
}
pub fn build(self) -> Result<Client, SdkError> {
ensure_rustls_provider();
let tokens = self
.token_provider
.ok_or_else(|| SdkError::Config("no authentication configured".into()))?;
let base_url = self
.base_url
.ok_or_else(|| SdkError::Config("no base URL configured".into()))?;
let user_agent = self.user_agent.unwrap_or_else(|| {
format!(
"{}/{}",
openapp_sdk_common::SDK_NAME,
openapp_sdk_common::SDK_VERSION
)
});
let underlying = self.underlying.unwrap_or_else(|| {
reqwest::Client::builder()
.user_agent(user_agent.clone())
.pool_idle_timeout(Some(Duration::from_secs(90)))
.build()
.expect("reqwest::Client defaults must build")
});
let backoff = ExponentialBackoff::builder()
.retry_bounds(self.retry.initial_backoff, self.retry.max_backoff)
.base(2)
.build_with_max_retries(self.retry.max_retries);
let client = MiddlewareBuilder::new(underlying)
.with(RetryTransientMiddleware::new_with_policy(backoff))
.build();
let config = ClientConfig {
base_url: base_url.clone(),
user_agent: user_agent.clone(),
default_timeout: self.default_timeout,
retry: self.retry,
};
let transport = Transport::new(
client,
base_url,
user_agent,
tokens,
self.interceptors,
self.default_timeout,
);
Ok(Client {
transport: Arc::new(transport),
config,
})
}
}
#[derive(Debug)]
struct FailingProvider(String);
#[async_trait::async_trait]
impl crate::auth::TokenProvider for FailingProvider {
async fn token(&self) -> Result<crate::auth::AuthToken, SdkError> {
Err(SdkError::Auth(self.0.clone()))
}
}
#[derive(Debug, Clone)]
pub struct Client {
transport: Arc<Transport>,
config: ClientConfig,
}
impl Client {
#[must_use]
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
#[must_use]
pub fn config(&self) -> &ClientConfig {
&self.config
}
#[must_use]
pub fn transport(&self) -> Arc<Transport> {
self.transport.clone()
}
#[must_use]
pub fn api_keys(&self) -> resources::ApiKeysClient {
resources::ApiKeysClient::new(self.transport.clone())
}
#[must_use]
pub fn users(&self) -> resources::UsersClient {
resources::UsersClient::new(self.transport.clone())
}
#[must_use]
pub fn orgs(&self) -> resources::OrgsClient {
resources::OrgsClient::new(self.transport.clone())
}
#[must_use]
pub fn devices(&self) -> resources::DevicesClient {
resources::DevicesClient::new(self.transport.clone())
}
#[must_use]
pub fn billing(&self) -> resources::BillingClient {
resources::BillingClient::new(self.transport.clone())
}
#[must_use]
pub fn entities(&self) -> resources::EntitiesClient {
resources::EntitiesClient::new(self.transport.clone())
}
#[must_use]
pub fn integrations(&self) -> resources::IntegrationsClient {
resources::IntegrationsClient::new(self.transport.clone())
}
#[must_use]
pub fn zones(&self) -> resources::ZonesClient {
resources::ZonesClient::new(self.transport.clone())
}
#[must_use]
pub fn lan_agent(&self) -> resources::LanAgentClient {
resources::LanAgentClient::new(self.transport.clone())
}
#[must_use]
pub fn scripting(&self) -> resources::ScriptingClient {
resources::ScriptingClient::new(self.transport.clone())
}
#[must_use]
pub fn apartment_residents(&self) -> resources::ApartmentResidentsClient {
resources::ApartmentResidentsClient::new(self.transport.clone())
}
#[must_use]
pub fn public_access(&self) -> resources::PublicAccessClient {
resources::PublicAccessClient::new(self.transport.clone())
}
#[must_use]
pub fn auth(&self) -> resources::AuthClient {
resources::AuthClient::new(self.transport.clone())
}
#[must_use]
pub fn me(&self) -> resources::MeClient {
resources::MeClient::new(self.transport.clone())
}
#[must_use]
pub fn eula(&self) -> resources::EulaClient {
resources::EulaClient::new(self.transport.clone())
}
#[must_use]
pub fn status(&self) -> resources::StatusClient {
resources::StatusClient::new(self.transport.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_requires_auth() {
let err = Client::builder().build().unwrap_err();
assert!(matches!(err, SdkError::Config(_)));
}
#[test]
fn api_key_derives_base_url() {
let client = Client::builder()
.api_key("https://api.openapp.house/api/v1_openapp_SECRET")
.build()
.unwrap();
assert_eq!(
client.config().base_url.as_str(),
"https://api.openapp.house/api/v1"
);
}
#[test]
fn deferred_token_error_surfaces_at_request_time() {
let client = Client::builder()
.api_key("not a token")
.base_url("https://api.openapp.house/api/v1")
.unwrap()
.build()
.unwrap();
let _ = client; }
}