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}
41
42#[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 #[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 self.token_provider = Some(Arc::new(FailingProvider(err.to_string())));
83 }
84 }
85 self
86 }
87
88 #[must_use]
90 pub fn token_provider(mut self, provider: SharedTokenProvider) -> Self {
91 self.token_provider = Some(provider);
92 self
93 }
94
95 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 #[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 #[must_use]
113 pub fn default_timeout(mut self, timeout: Duration) -> Self {
114 self.default_timeout = timeout;
115 self
116 }
117
118 #[must_use]
120 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
121 self.retry = policy;
122 self
123 }
124
125 #[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 #[must_use]
134 pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
135 self.underlying = Some(client);
136 self
137 }
138
139 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#[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#[derive(Debug, Clone)]
223pub struct Client {
224 transport: Arc<Transport>,
225 config: ClientConfig,
226}
227
228impl Client {
229 #[must_use]
231 pub fn builder() -> ClientBuilder {
232 ClientBuilder::default()
233 }
234
235 #[must_use]
237 pub fn config(&self) -> &ClientConfig {
238 &self.config
239 }
240
241 #[must_use]
243 pub fn transport(&self) -> Arc<Transport> {
244 self.transport.clone()
245 }
246
247 #[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 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; }
364}