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    pub default_org: Option<String>,
41}
42
43/// Fluent builder for [`Client`]. Use [`Client::builder`] to construct one.
44#[derive(Debug)]
45pub struct ClientBuilder {
46    token_provider: Option<SharedTokenProvider>,
47    base_url: Option<Url>,
48    user_agent: Option<String>,
49    default_timeout: Duration,
50    retry: RetryPolicy,
51    interceptors: Vec<SharedInterceptor>,
52    underlying: Option<reqwest::Client>,
53    org: Option<String>,
54}
55
56impl Default for ClientBuilder {
57    fn default() -> Self {
58        Self {
59            token_provider: None,
60            base_url: None,
61            user_agent: None,
62            default_timeout: Duration::from_secs(30),
63            retry: RetryPolicy::default(),
64            interceptors: vec![Arc::new(TracingInterceptor) as SharedInterceptor],
65            underlying: None,
66            org: None,
67        }
68    }
69}
70
71impl ClientBuilder {
72    /// Authenticate with a static `OpenApp` API key. The API root (`{origin}/api/v1`) is
73    /// derived from the origin embedded in the token unless overridden via
74    /// [`ClientBuilder::base_url`].
75    #[must_use]
76    pub fn api_key(mut self, token: impl Into<String>) -> Self {
77        match StaticApiKey::from_raw(token) {
78            Ok(provider) => {
79                if self.base_url.is_none() {
80                    self.base_url = Some(provider.api_key().api_base_url());
81                }
82                self.token_provider = Some(Arc::new(provider));
83            }
84            Err(err) => {
85                // Defer the error to `.build()` so the fluent builder keeps composing.
86                self.token_provider = Some(Arc::new(FailingProvider(err.to_string())));
87            }
88        }
89        self
90    }
91
92    /// Use a custom [`TokenProvider`](crate::auth::TokenProvider).
93    #[must_use]
94    pub fn token_provider(mut self, provider: SharedTokenProvider) -> Self {
95        self.token_provider = Some(provider);
96        self
97    }
98
99    /// Override the API root every request path is resolved against (for example
100    /// `https://openapp.house/api/v1`, including the `/api/v1` prefix). Rarely needed —
101    /// [`ClientBuilder::api_key`] derives it from the token's origin.
102    pub fn base_url(mut self, url: impl AsRef<str>) -> Result<Self, SdkError> {
103        let parsed = Url::parse(url.as_ref())
104            .map_err(|e| SdkError::Config(format!("invalid base_url: {e}")))?;
105        self.base_url = Some(parsed);
106        Ok(self)
107    }
108
109    /// Set the `User-Agent` header. Defaults to `"openapp-sdk/<version>"`.
110    #[must_use]
111    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
112        self.user_agent = Some(ua.into());
113        self
114    }
115
116    /// Set the default per-request timeout.
117    #[must_use]
118    pub fn default_timeout(mut self, timeout: Duration) -> Self {
119        self.default_timeout = timeout;
120        self
121    }
122
123    /// Override the default retry policy.
124    #[must_use]
125    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
126        self.retry = policy;
127        self
128    }
129
130    /// Set the default organization context (`X-Org` header) for every request.
131    #[must_use]
132    pub fn org(mut self, org: impl Into<String>) -> Self {
133        self.org = Some(org.into());
134        self
135    }
136
137    /// Add an [`Interceptor`]. Interceptors run in insertion order.
138    #[must_use]
139    pub fn interceptor(mut self, interceptor: impl Interceptor + 'static) -> Self {
140        self.interceptors.push(Arc::new(interceptor));
141        self
142    }
143
144    /// Supply a pre-built `reqwest::Client` (e.g. with a custom TLS root bundle).
145    #[must_use]
146    pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
147        self.underlying = Some(client);
148        self
149    }
150
151    /// Finalize the builder into a [`Client`].
152    ///
153    /// # Errors
154    ///
155    /// Returns [`SdkError::Config`] when no authentication or base URL has been
156    /// configured, or when a token-provider error has been deferred from
157    /// [`ClientBuilder::api_key`].
158    ///
159    /// # Panics
160    ///
161    /// Panics if the default [`reqwest::Client`] cannot be built. In practice this
162    /// only happens if the host is missing TLS roots, which is treated as a
163    /// programmer error rather than a recoverable condition.
164    pub fn build(self) -> Result<Client, SdkError> {
165        ensure_rustls_provider();
166
167        let tokens = self
168            .token_provider
169            .ok_or_else(|| SdkError::Config("no authentication configured".into()))?;
170        let base_url = self
171            .base_url
172            .ok_or_else(|| SdkError::Config("no base URL configured".into()))?;
173
174        let user_agent = self.user_agent.unwrap_or_else(|| {
175            format!(
176                "{}/{}",
177                openapp_sdk_common::SDK_NAME,
178                openapp_sdk_common::SDK_VERSION
179            )
180        });
181
182        let underlying = self.underlying.unwrap_or_else(|| {
183            reqwest::Client::builder()
184                .user_agent(user_agent.clone())
185                .pool_idle_timeout(Some(Duration::from_secs(90)))
186                .build()
187                .expect("reqwest::Client defaults must build")
188        });
189
190        let backoff = ExponentialBackoff::builder()
191            .retry_bounds(self.retry.initial_backoff, self.retry.max_backoff)
192            .base(2)
193            .build_with_max_retries(self.retry.max_retries);
194
195        let client = MiddlewareBuilder::new(underlying)
196            .with(RetryTransientMiddleware::new_with_policy(backoff))
197            .build();
198
199        let config = ClientConfig {
200            base_url: base_url.clone(),
201            user_agent: user_agent.clone(),
202            default_timeout: self.default_timeout,
203            retry: self.retry,
204            default_org: self.org.clone(),
205        };
206
207        let transport = Transport::new(
208            client,
209            base_url,
210            user_agent,
211            tokens,
212            self.interceptors,
213            self.default_timeout,
214            self.org,
215        );
216
217        Ok(Client {
218            transport: Arc::new(transport),
219            config,
220        })
221    }
222}
223
224/// Fails every token request with the deferred parse error captured at build time.
225#[derive(Debug)]
226struct FailingProvider(String);
227
228#[async_trait::async_trait]
229impl crate::auth::TokenProvider for FailingProvider {
230    async fn token(&self) -> Result<crate::auth::AuthToken, SdkError> {
231        Err(SdkError::Auth(self.0.clone()))
232    }
233}
234
235/// High-level SDK client. Cheap to clone — all heavy state sits behind an `Arc`.
236#[derive(Debug, Clone)]
237pub struct Client {
238    transport: Arc<Transport>,
239    config: ClientConfig,
240}
241
242impl Client {
243    /// Start a [`ClientBuilder`].
244    #[must_use]
245    pub fn builder() -> ClientBuilder {
246        ClientBuilder::default()
247    }
248
249    /// Snapshot of the effective configuration.
250    #[must_use]
251    pub fn config(&self) -> &ClientConfig {
252        &self.config
253    }
254
255    /// Transport engine — exposed for advanced callers (the `PyO3` bridge, tests).
256    #[must_use]
257    pub fn transport(&self) -> Arc<Transport> {
258        self.transport.clone()
259    }
260
261    /// Return a client scoped to a different organization (`X-Org` header).
262    #[must_use]
263    pub fn with_org(&self, org: impl Into<String>) -> Client {
264        let org = org.into();
265        let transport = Arc::new(self.transport.with_default_org(org.clone()));
266        Client {
267            transport,
268            config: ClientConfig {
269                default_org: Some(org),
270                ..self.config.clone()
271            },
272        }
273    }
274
275    // -- Per-tag sub-clients -------------------------------------------------
276
277    #[must_use]
278    pub fn api_keys(&self) -> resources::ApiKeysClient {
279        resources::ApiKeysClient::new(self.transport.clone())
280    }
281
282    #[must_use]
283    pub fn agents(&self) -> resources::AgentsClient {
284        resources::AgentsClient::new(self.transport.clone())
285    }
286
287    #[must_use]
288    pub fn users(&self) -> resources::UsersClient {
289        resources::UsersClient::new(self.transport.clone())
290    }
291
292    #[must_use]
293    pub fn orgs(&self) -> resources::OrgsClient {
294        resources::OrgsClient::new(self.transport.clone())
295    }
296
297    #[must_use]
298    pub fn devices(&self) -> resources::DevicesClient {
299        resources::DevicesClient::new(self.transport.clone())
300    }
301
302    #[must_use]
303    pub fn billing(&self) -> resources::BillingClient {
304        resources::BillingClient::new(self.transport.clone())
305    }
306
307    #[must_use]
308    pub fn entities(&self) -> resources::EntitiesClient {
309        resources::EntitiesClient::new(self.transport.clone())
310    }
311
312    #[must_use]
313    pub fn integrations(&self) -> resources::IntegrationsClient {
314        resources::IntegrationsClient::new(self.transport.clone())
315    }
316
317    #[must_use]
318    pub fn zones(&self) -> resources::ZonesClient {
319        resources::ZonesClient::new(self.transport.clone())
320    }
321
322    #[must_use]
323    pub fn lan_agent(&self) -> resources::LanAgentClient {
324        resources::LanAgentClient::new(self.transport.clone())
325    }
326
327    #[must_use]
328    pub fn scripting(&self) -> resources::ScriptingClient {
329        resources::ScriptingClient::new(self.transport.clone())
330    }
331
332    #[must_use]
333    pub fn directory_listing_members(&self) -> resources::DirectoryListingMembersClient {
334        resources::DirectoryListingMembersClient::new(self.transport.clone())
335    }
336
337    #[must_use]
338    pub fn public_access(&self) -> resources::PublicAccessClient {
339        resources::PublicAccessClient::new(self.transport.clone())
340    }
341
342    #[must_use]
343    pub fn auth(&self) -> resources::AuthClient {
344        resources::AuthClient::new(self.transport.clone())
345    }
346
347    #[must_use]
348    pub fn me(&self) -> resources::MeClient {
349        resources::MeClient::new(self.transport.clone())
350    }
351
352    #[must_use]
353    pub fn eula(&self) -> resources::EulaClient {
354        resources::EulaClient::new(self.transport.clone())
355    }
356
357    #[must_use]
358    pub fn status(&self) -> resources::StatusClient {
359        resources::StatusClient::new(self.transport.clone())
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn build_requires_auth() {
369        let err = Client::builder().build().unwrap_err();
370        assert!(matches!(err, SdkError::Config(_)));
371    }
372
373    #[test]
374    fn api_key_derives_versioned_api_root_from_origin() {
375        let client = Client::builder()
376            .api_key("https://openapp.house_openapp_SECRET")
377            .build()
378            .unwrap();
379        assert_eq!(
380            client.config().base_url.as_str(),
381            "https://openapp.house/api/v1"
382        );
383    }
384
385    #[test]
386    fn explicit_base_url_overrides_derived_root() {
387        let client = Client::builder()
388            .base_url("http://localhost:4455/api/v1")
389            .unwrap()
390            .api_key("http://oathkeeper:4455_openapp_SECRET")
391            .build()
392            .unwrap();
393        assert_eq!(
394            client.config().base_url.as_str(),
395            "http://localhost:4455/api/v1"
396        );
397    }
398
399    #[test]
400    fn org_sets_default_org_on_config() {
401        let client = Client::builder()
402            .api_key("https://openapp.house_openapp_SECRET")
403            .org("01HORG00000000000000000000")
404            .build()
405            .unwrap();
406        assert_eq!(
407            client.config().default_org.as_deref(),
408            Some("01HORG00000000000000000000")
409        );
410        assert_eq!(
411            client.transport().default_org(),
412            Some("01HORG00000000000000000000")
413        );
414    }
415
416    #[test]
417    fn with_org_returns_scoped_client() {
418        let client = Client::builder()
419            .api_key("https://openapp.house_openapp_SECRET")
420            .build()
421            .unwrap();
422        let scoped = client.with_org("01HORG00000000000000000001");
423        assert_eq!(
424            scoped.config().default_org.as_deref(),
425            Some("01HORG00000000000000000001")
426        );
427        assert!(client.config().default_org.is_none());
428    }
429
430    #[test]
431    fn deferred_token_error_surfaces_at_request_time() {
432        // Malformed token: no separator. `build()` still succeeds; the error surfaces
433        // when the first request asks the provider for a token.
434        let client = Client::builder()
435            .api_key("not a token")
436            .base_url("https://openapp.house/api/v1")
437            .unwrap()
438            .build()
439            .unwrap();
440        let _ = client; // the error path is exercised by transport tests
441    }
442}