openapp_sdk_core/
client.rs1use 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 let _ = rustls::crypto::ring::default_provider().install_default();
30 });
31}
32
33#[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#[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 #[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 self.token_provider = Some(Arc::new(FailingProvider(err.to_string())));
87 }
88 }
89 self
90 }
91
92 #[must_use]
94 pub fn token_provider(mut self, provider: SharedTokenProvider) -> Self {
95 self.token_provider = Some(provider);
96 self
97 }
98
99 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 #[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 #[must_use]
118 pub fn default_timeout(mut self, timeout: Duration) -> Self {
119 self.default_timeout = timeout;
120 self
121 }
122
123 #[must_use]
125 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
126 self.retry = policy;
127 self
128 }
129
130 #[must_use]
132 pub fn org(mut self, org: impl Into<String>) -> Self {
133 self.org = Some(org.into());
134 self
135 }
136
137 #[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 #[must_use]
146 pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
147 self.underlying = Some(client);
148 self
149 }
150
151 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#[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#[derive(Debug, Clone)]
237pub struct Client {
238 transport: Arc<Transport>,
239 config: ClientConfig,
240}
241
242impl Client {
243 #[must_use]
245 pub fn builder() -> ClientBuilder {
246 ClientBuilder::default()
247 }
248
249 #[must_use]
251 pub fn config(&self) -> &ClientConfig {
252 &self.config
253 }
254
255 #[must_use]
257 pub fn transport(&self) -> Arc<Transport> {
258 self.transport.clone()
259 }
260
261 #[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 #[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 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; }
442}