Skip to main content

openapp_sdk_core/
client.rs

1//! The top-level [`Client`] and its builder.
2//!
3//! A `Client` is the user-facing entry point: it owns the transport engine and hands
4//! out per-tag sub-clients (orgs, devices, entities, …) on demand.
5
6use std::{
7    sync::{Arc, Once},
8    time::Duration,
9};
10
11use reqwest_middleware::ClientBuilder as MiddlewareBuilder;
12use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
13use url::Url;
14
15use crate::{
16    auth::{SharedTokenProvider, StaticApiKey},
17    error::SdkError,
18    interceptor::{Interceptor, SharedInterceptor, TracingInterceptor},
19    resources,
20    retry::RetryPolicy,
21    transport::Transport,
22};
23
24static RUSTLS_PROVIDER_INIT: Once = Once::new();
25
26fn ensure_rustls_provider() {
27    RUSTLS_PROVIDER_INIT.call_once(|| {
28        // Safe to ignore if another provider is already installed globally.
29        let _ = rustls::crypto::ring::default_provider().install_default();
30    });
31}
32
33/// User-visible client configuration snapshot (read-only after [`ClientBuilder::build`]).
34#[derive(Debug, Clone)]
35pub struct ClientConfig {
36    pub base_url: Url,
37    pub user_agent: String,
38    pub default_timeout: Duration,
39    pub retry: RetryPolicy,
40}
41
42/// Fluent builder for [`Client`]. Use [`Client::builder`] to construct one.
43#[derive(Debug)]
44pub struct ClientBuilder {
45    token_provider: Option<SharedTokenProvider>,
46    base_url: Option<Url>,
47    user_agent: Option<String>,
48    default_timeout: Duration,
49    retry: RetryPolicy,
50    interceptors: Vec<SharedInterceptor>,
51    underlying: Option<reqwest::Client>,
52}
53
54impl Default for ClientBuilder {
55    fn default() -> Self {
56        Self {
57            token_provider: None,
58            base_url: None,
59            user_agent: None,
60            default_timeout: Duration::from_secs(30),
61            retry: RetryPolicy::default(),
62            interceptors: vec![Arc::new(TracingInterceptor) as SharedInterceptor],
63            underlying: None,
64        }
65    }
66}
67
68impl ClientBuilder {
69    /// Authenticate with a static `OpenApp` API key. The base URL is derived from the
70    /// token unless overridden via [`ClientBuilder::base_url`].
71    #[must_use]
72    pub fn api_key(mut self, token: impl Into<String>) -> Self {
73        match StaticApiKey::from_raw(token) {
74            Ok(provider) => {
75                if self.base_url.is_none() {
76                    self.base_url = Some(provider.api_key().base_url().clone());
77                }
78                self.token_provider = Some(Arc::new(provider));
79            }
80            Err(err) => {
81                // Defer the error to `.build()` so the fluent builder keeps composing.
82                self.token_provider = Some(Arc::new(FailingProvider(err.to_string())));
83            }
84        }
85        self
86    }
87
88    /// Use a custom [`TokenProvider`](crate::auth::TokenProvider).
89    #[must_use]
90    pub fn token_provider(mut self, provider: SharedTokenProvider) -> Self {
91        self.token_provider = Some(provider);
92        self
93    }
94
95    /// Override the API base URL. Rarely needed — an `OpenApp` API key embeds its base
96    /// URL, so calling [`ClientBuilder::api_key`] usually suffices.
97    pub fn base_url(mut self, url: impl AsRef<str>) -> Result<Self, SdkError> {
98        let parsed = Url::parse(url.as_ref())
99            .map_err(|e| SdkError::Config(format!("invalid base_url: {e}")))?;
100        self.base_url = Some(parsed);
101        Ok(self)
102    }
103
104    /// Set the `User-Agent` header. Defaults to `"openapp-sdk/<version>"`.
105    #[must_use]
106    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
107        self.user_agent = Some(ua.into());
108        self
109    }
110
111    /// Set the default per-request timeout.
112    #[must_use]
113    pub fn default_timeout(mut self, timeout: Duration) -> Self {
114        self.default_timeout = timeout;
115        self
116    }
117
118    /// Override the default retry policy.
119    #[must_use]
120    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
121        self.retry = policy;
122        self
123    }
124
125    /// Add an [`Interceptor`]. Interceptors run in insertion order.
126    #[must_use]
127    pub fn interceptor(mut self, interceptor: impl Interceptor + 'static) -> Self {
128        self.interceptors.push(Arc::new(interceptor));
129        self
130    }
131
132    /// Supply a pre-built `reqwest::Client` (e.g. with a custom TLS root bundle).
133    #[must_use]
134    pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
135        self.underlying = Some(client);
136        self
137    }
138
139    /// Finalize the builder into a [`Client`].
140    ///
141    /// # Errors
142    ///
143    /// Returns [`SdkError::Config`] when no authentication or base URL has been
144    /// configured, or when a token-provider error has been deferred from
145    /// [`ClientBuilder::api_key`].
146    ///
147    /// # Panics
148    ///
149    /// Panics if the default [`reqwest::Client`] cannot be built. In practice this
150    /// only happens if the host is missing TLS roots, which is treated as a
151    /// programmer error rather than a recoverable condition.
152    pub fn build(self) -> Result<Client, SdkError> {
153        ensure_rustls_provider();
154
155        let tokens = self
156            .token_provider
157            .ok_or_else(|| SdkError::Config("no authentication configured".into()))?;
158        let base_url = self
159            .base_url
160            .ok_or_else(|| SdkError::Config("no base URL configured".into()))?;
161
162        let user_agent = self.user_agent.unwrap_or_else(|| {
163            format!(
164                "{}/{}",
165                openapp_sdk_common::SDK_NAME,
166                openapp_sdk_common::SDK_VERSION
167            )
168        });
169
170        let underlying = self.underlying.unwrap_or_else(|| {
171            reqwest::Client::builder()
172                .user_agent(user_agent.clone())
173                .pool_idle_timeout(Some(Duration::from_secs(90)))
174                .build()
175                .expect("reqwest::Client defaults must build")
176        });
177
178        let backoff = ExponentialBackoff::builder()
179            .retry_bounds(self.retry.initial_backoff, self.retry.max_backoff)
180            .base(2)
181            .build_with_max_retries(self.retry.max_retries);
182
183        let client = MiddlewareBuilder::new(underlying)
184            .with(RetryTransientMiddleware::new_with_policy(backoff))
185            .build();
186
187        let config = ClientConfig {
188            base_url: base_url.clone(),
189            user_agent: user_agent.clone(),
190            default_timeout: self.default_timeout,
191            retry: self.retry,
192        };
193
194        let transport = Transport::new(
195            client,
196            base_url,
197            user_agent,
198            tokens,
199            self.interceptors,
200            self.default_timeout,
201        );
202
203        Ok(Client {
204            transport: Arc::new(transport),
205            config,
206        })
207    }
208}
209
210/// Fails every token request with the deferred parse error captured at build time.
211#[derive(Debug)]
212struct FailingProvider(String);
213
214#[async_trait::async_trait]
215impl crate::auth::TokenProvider for FailingProvider {
216    async fn token(&self) -> Result<crate::auth::AuthToken, SdkError> {
217        Err(SdkError::Auth(self.0.clone()))
218    }
219}
220
221/// High-level SDK client. Cheap to clone — all heavy state sits behind an `Arc`.
222#[derive(Debug, Clone)]
223pub struct Client {
224    transport: Arc<Transport>,
225    config: ClientConfig,
226}
227
228impl Client {
229    /// Start a [`ClientBuilder`].
230    #[must_use]
231    pub fn builder() -> ClientBuilder {
232        ClientBuilder::default()
233    }
234
235    /// Snapshot of the effective configuration.
236    #[must_use]
237    pub fn config(&self) -> &ClientConfig {
238        &self.config
239    }
240
241    /// Transport engine — exposed for advanced callers (the `PyO3` bridge, tests).
242    #[must_use]
243    pub fn transport(&self) -> Arc<Transport> {
244        self.transport.clone()
245    }
246
247    // -- Per-tag sub-clients -------------------------------------------------
248
249    #[must_use]
250    pub fn api_keys(&self) -> resources::ApiKeysClient {
251        resources::ApiKeysClient::new(self.transport.clone())
252    }
253
254    #[must_use]
255    pub fn users(&self) -> resources::UsersClient {
256        resources::UsersClient::new(self.transport.clone())
257    }
258
259    #[must_use]
260    pub fn orgs(&self) -> resources::OrgsClient {
261        resources::OrgsClient::new(self.transport.clone())
262    }
263
264    #[must_use]
265    pub fn devices(&self) -> resources::DevicesClient {
266        resources::DevicesClient::new(self.transport.clone())
267    }
268
269    #[must_use]
270    pub fn billing(&self) -> resources::BillingClient {
271        resources::BillingClient::new(self.transport.clone())
272    }
273
274    #[must_use]
275    pub fn entities(&self) -> resources::EntitiesClient {
276        resources::EntitiesClient::new(self.transport.clone())
277    }
278
279    #[must_use]
280    pub fn integrations(&self) -> resources::IntegrationsClient {
281        resources::IntegrationsClient::new(self.transport.clone())
282    }
283
284    #[must_use]
285    pub fn zones(&self) -> resources::ZonesClient {
286        resources::ZonesClient::new(self.transport.clone())
287    }
288
289    #[must_use]
290    pub fn lan_agent(&self) -> resources::LanAgentClient {
291        resources::LanAgentClient::new(self.transport.clone())
292    }
293
294    #[must_use]
295    pub fn scripting(&self) -> resources::ScriptingClient {
296        resources::ScriptingClient::new(self.transport.clone())
297    }
298
299    #[must_use]
300    pub fn apartment_residents(&self) -> resources::ApartmentResidentsClient {
301        resources::ApartmentResidentsClient::new(self.transport.clone())
302    }
303
304    #[must_use]
305    pub fn public_access(&self) -> resources::PublicAccessClient {
306        resources::PublicAccessClient::new(self.transport.clone())
307    }
308
309    #[must_use]
310    pub fn auth(&self) -> resources::AuthClient {
311        resources::AuthClient::new(self.transport.clone())
312    }
313
314    #[must_use]
315    pub fn me(&self) -> resources::MeClient {
316        resources::MeClient::new(self.transport.clone())
317    }
318
319    #[must_use]
320    pub fn eula(&self) -> resources::EulaClient {
321        resources::EulaClient::new(self.transport.clone())
322    }
323
324    #[must_use]
325    pub fn status(&self) -> resources::StatusClient {
326        resources::StatusClient::new(self.transport.clone())
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn build_requires_auth() {
336        let err = Client::builder().build().unwrap_err();
337        assert!(matches!(err, SdkError::Config(_)));
338    }
339
340    #[test]
341    fn api_key_derives_base_url() {
342        let client = Client::builder()
343            .api_key("https://api.openapp.house/api/v1_openapp_SECRET")
344            .build()
345            .unwrap();
346        assert_eq!(
347            client.config().base_url.as_str(),
348            "https://api.openapp.house/api/v1"
349        );
350    }
351
352    #[test]
353    fn deferred_token_error_surfaces_at_request_time() {
354        // Malformed token: no separator. `build()` still succeeds; the error surfaces
355        // when the first request asks the provider for a token.
356        let client = Client::builder()
357            .api_key("not a token")
358            .base_url("https://api.openapp.house/api/v1")
359            .unwrap()
360            .build()
361            .unwrap();
362        let _ = client; // the error path is exercised by transport tests
363    }
364}