1use std::fmt::{self, Debug, Formatter};
4use std::sync::Arc;
5
6use reqwest::Url;
7use reqwest_middleware::ClientWithMiddleware;
8#[cfg(feature = "tables")]
9use uuid::Uuid;
10
11use crate::auth::{
12 CredentialChain, OidcAuthProvider, Token, TokenSource, default_auth_url, token_endpoint,
13};
14#[cfg(feature = "private-key-auth")]
15use crate::auth::{PrivateKeyCredentials, PrivateKeyJwtProvider};
16use crate::http::{HttpClient, default_http_client};
17use crate::resources::applications::{
18 ApplicationService, ApplicationsClient, ExecutionSettings, InstallationsClient,
19 clients as application_clients,
20};
21use crate::resources::arbor::Client as ArborClient;
22use crate::resources::catalog::Client as CatalogClient;
23use crate::resources::connection::HttpExternalConnectionClient;
24use crate::resources::location::HttpLocationClient;
25use crate::resources::organization::{Organization, OrganizationAdminClient};
26use crate::resources::project::{AuthorizationClient, ProjectClient};
27use crate::resources::task::HttpTaskClient;
28use crate::resources::vector_index::HttpVectorIndexClient;
29use crate::resources::workload::WorkloadClient;
30#[cfg(feature = "tables")]
31use crate::scandal::Scandal;
32#[cfg(feature = "tables")]
33use crate::scandal_backend::ScandalBackend;
34#[cfg(feature = "tables")]
35use crate::table::{Table, TableIdentity};
36use crate::{Config, Error};
37#[cfg(feature = "tables")]
38use crate::{Functions, RemoteScandal};
39#[cfg(feature = "embedded")]
40use crate::{NativeScandal, ResourceUriResolver, ScandalConfig};
41
42pub(crate) struct Connection {
43 http: HttpClient,
44 projects: ProjectClient,
45 organizations: Organization,
46 workloads: WorkloadClient,
47 authorization: AuthorizationClient,
48 pub(crate) locations: HttpLocationClient,
49 connections: HttpExternalConnectionClient,
50 pub(crate) tasks: HttpTaskClient,
51 pub(crate) vector_indexes: HttpVectorIndexClient,
52 auth: Option<TokenSource>,
53 applications: Arc<ApplicationService>,
54 pub(crate) arbor: ArborClient,
55 pub(crate) catalog: CatalogClient,
56 #[cfg(feature = "tables")]
57 tables: Option<Arc<ScandalBackend>>,
58}
59
60impl Connection {
61 #[cfg(feature = "tables")]
63 pub(crate) fn tables(&self) -> Result<&ScandalBackend, Error> {
64 self.tables.as_deref().ok_or_else(|| {
65 Error::invalid("no scandal selected; configure Client::builder().scandal(...)")
66 })
67 }
68}
69
70#[derive(Clone)]
72pub struct Client {
73 pub(crate) connection: Arc<Connection>,
74}
75
76impl Debug for Client {
77 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
78 formatter.debug_struct("Client").finish_non_exhaustive()
79 }
80}
81
82impl Client {
83 pub fn builder() -> ClientBuilder {
85 ClientBuilder::default()
86 }
87
88 pub async fn from_env() -> Result<Self, Error> {
90 Self::from_config(&Config::load().await?)
91 }
92
93 pub fn from_config(config: &Config) -> Result<Self, Error> {
95 Self::builder().config(config).build()
96 }
97
98 pub fn http(&self) -> &HttpClient {
100 &self.connection.http
101 }
102
103 pub async fn access_token(&self) -> Result<Token, Error> {
105 self.connection
106 .auth
107 .as_ref()
108 .ok_or(Error::CredentialsNotFound)?
109 .token()
110 .await?
111 .ok_or(Error::CredentialsNotFound)
112 }
113
114 pub fn projects(&self) -> &ProjectClient {
116 &self.connection.projects
117 }
118
119 pub fn authorization(&self) -> &AuthorizationClient {
121 &self.connection.authorization
122 }
123
124 pub fn organization(&self) -> &Organization {
126 &self.connection.organizations
127 }
128
129 pub fn organization_admin(&self) -> &OrganizationAdminClient {
131 &self.connection.organizations
132 }
133
134 pub fn workloads(&self) -> &WorkloadClient {
136 &self.connection.workloads
137 }
138
139 pub fn applications(&self) -> ApplicationsClient {
141 ApplicationsClient {
142 service: self.connection.applications.clone(),
143 }
144 }
145
146 pub fn installations(&self) -> InstallationsClient {
148 InstallationsClient {
149 service: self.connection.applications.clone(),
150 connection: self.clone(),
151 }
152 }
153
154 pub fn tables(&self) -> &ArborClient {
156 &self.connection.arbor
157 }
158
159 pub fn catalog(&self) -> &CatalogClient {
161 &self.connection.catalog
162 }
163
164 pub fn locations(&self) -> &HttpLocationClient {
166 &self.connection.locations
167 }
168
169 pub fn connections(&self) -> &HttpExternalConnectionClient {
171 &self.connection.connections
172 }
173
174 pub fn tasks(&self) -> &HttpTaskClient {
176 &self.connection.tasks
177 }
178
179 pub fn vector_indexes(&self) -> &HttpVectorIndexClient {
181 &self.connection.vector_indexes
182 }
183
184 #[cfg(feature = "tables")]
186 pub async fn functions(&self) -> Result<Functions, Error> {
187 Functions::new(self.connection.tables()?.scandal()).await
188 }
189
190 #[cfg(feature = "tables")]
192 pub fn table_by_id(&self, resource_id: Uuid, version_id: Uuid) -> Table {
193 Table::new(
194 Arc::clone(&self.connection),
195 TableIdentity {
196 resource_id,
197 version_id,
198 },
199 )
200 }
201}
202
203#[derive(Default)]
205pub struct ClientBuilder {
206 api_url: Option<String>,
207 auth_url: Option<String>,
208 scandal_url: Option<String>,
209 authentication: Option<ClientAuthentication>,
210 http: Option<ClientWithMiddleware>,
211 #[cfg(feature = "tables")]
212 scandal: Option<Arc<dyn Scandal>>,
213 #[cfg(feature = "embedded")]
214 embedded: Option<EmbeddedScandals>,
215}
216
217enum ClientAuthentication {
218 Provider(TokenSource),
219 #[cfg(feature = "private-key-auth")]
220 PrivateKey(PrivateKeyCredentials),
221 TrustedIssuer(String, TokenSource),
222}
223
224impl ClientBuilder {
225 pub fn config(mut self, config: &Config) -> Self {
227 self.api_url = Some(config.api_url.to_string());
228 self.auth_url = Some(config.auth_url.to_string());
229 self.scandal_url = config.scandal_url.as_ref().map(ToString::to_string);
230 self.authentication = Some(ClientAuthentication::Provider(config.auth.clone()));
231 self.http = Some(config.http.clone());
232 self
233 }
234
235 pub fn api_url(mut self, url: impl Into<String>) -> Self {
237 self.api_url = Some(url.into());
238 self
239 }
240
241 pub fn auth_url(mut self, url: impl Into<String>) -> Self {
243 self.auth_url = Some(url.into());
244 self
245 }
246
247 #[cfg(feature = "private-key-auth")]
249 pub fn private_key(mut self, client_id: impl Into<String>, jwk: &str) -> Result<Self, Error> {
250 self.authentication = Some(ClientAuthentication::PrivateKey(
251 PrivateKeyCredentials::from_jwk(client_id.into(), jwk)?,
252 ));
253 Ok(self)
254 }
255
256 pub fn trusted_issuer(mut self, client_id: impl Into<String>, source: TokenSource) -> Self {
258 self.authentication = Some(ClientAuthentication::TrustedIssuer(
259 client_id.into(),
260 source,
261 ));
262 self
263 }
264
265 pub fn scandal_url(mut self, url: impl Into<String>) -> Self {
271 self.scandal_url = Some(url.into());
272 self
273 }
274
275 pub fn http_client(mut self, http: impl Into<ClientWithMiddleware>) -> Self {
278 self.http = Some(http.into());
279 self
280 }
281
282 pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
284 self.authentication = Some(ClientAuthentication::Provider(TokenSource::fixed(
285 token.into().into(),
286 )));
287 self
288 }
289
290 pub fn token_source(mut self, provider: TokenSource) -> Self {
295 self.authentication = Some(ClientAuthentication::Provider(provider));
296 self
297 }
298
299 #[cfg(feature = "tables")]
305 pub fn scandal(mut self, scandal: Arc<dyn Scandal>) -> Self {
306 self.scandal = Some(scandal);
307 self
308 }
309
310 #[cfg(feature = "embedded")]
315 pub fn embedded_scandal(mut self, config: ScandalConfig) -> Self {
316 self.embedded = Some(EmbeddedScandals(Arc::new(move |http, caller, api_url| {
319 embedded(&config, http, caller, api_url)
320 })));
321 self
322 }
323
324 #[cfg(feature = "embedded")]
326 pub(crate) fn embedded_scandals(mut self, scandals: EmbeddedScandals) -> Self {
327 self.embedded = Some(scandals);
328 self
329 }
330
331 pub fn build(self) -> Result<Client, Error> {
333 let api = endpoint(
334 self.api_url
335 .as_deref()
336 .ok_or_else(|| Error::invalid("api_url is required"))?,
337 )?;
338 let http = self
339 .http
340 .unwrap_or_else(|| default_http_client().expect("valid HTTP defaults"));
341 let auth_url = self
342 .auth_url
343 .as_deref()
344 .map(endpoint)
345 .transpose()?
346 .unwrap_or_else(default_auth_url);
347 let auth: Option<TokenSource> = match self.authentication {
348 None => None,
349 Some(ClientAuthentication::Provider(provider)) => Some(provider),
350 #[cfg(feature = "private-key-auth")]
351 Some(ClientAuthentication::PrivateKey(credentials)) => Some(
352 CredentialChain::new(vec![
353 PrivateKeyJwtProvider::new(http.clone(), &auth_url)?
354 .with_credentials(credentials)
355 .into(),
356 ])
357 .into(),
358 ),
359 Some(ClientAuthentication::TrustedIssuer(id, source)) => Some(
360 CredentialChain::new(vec![
361 OidcAuthProvider::new(http.clone(), token_endpoint(&auth_url)?, id)
362 .with_assertion_provider(source)
363 .into(),
364 ])
365 .into(),
366 ),
367 };
368
369 let management = HttpClient::new(api.clone(), http.clone(), auth.clone());
370 #[cfg(feature = "tables")]
371 let mut selections = Vec::new();
372 #[cfg(feature = "tables")]
373 {
374 selections.extend(self.scandal.map(ScandalSelection::Given));
375 if let Some(url) = self.scandal_url {
376 selections.push(ScandalSelection::Remote(endpoint(&url)?));
377 }
378 }
379 #[cfg(feature = "embedded")]
380 selections.extend(self.embedded.map(ScandalSelection::Embedded));
381 #[cfg(feature = "tables")]
382 let selection = match selections.len() {
383 0 | 1 => selections.pop(),
384 _ => {
385 return Err(Error::invalid(
386 "select one of a scandal, a scandal endpoint or an embedded scandal",
387 ));
388 }
389 };
390 #[cfg(feature = "tables")]
391 let scandal = selection
392 .as_ref()
393 .map(|selection| selection.scandal(&http, auth.as_ref(), &auth_url, &api))
394 .transpose()?;
395 let applications = application_clients(
396 api.as_str(),
397 auth_url,
398 http,
399 auth.clone(),
400 ExecutionSettings {
401 #[cfg(feature = "tables")]
402 scandal: selection,
403 },
404 )?;
405
406 let arbor = ArborClient::from_http(management.clone())?;
407 let catalog = CatalogClient::from_http(management.clone())?;
408 #[cfg(feature = "tables")]
409 let tables = scandal.map(|scandal| Arc::new(ScandalBackend::new(scandal, arbor.clone())));
410 Ok(Client {
411 connection: Arc::new(Connection {
412 projects: ProjectClient::from_http(management.clone())?,
413 organizations: Organization::from_http(management.clone())?,
414 workloads: WorkloadClient::from_http(management.clone())?,
415 authorization: AuthorizationClient::from_http(management.clone())?,
416 locations: HttpLocationClient::from_http(management.clone())?,
417 connections: HttpExternalConnectionClient::from_http(management.clone())?,
418 tasks: HttpTaskClient::from_http(management.clone())?,
419 vector_indexes: HttpVectorIndexClient::from_http(management.clone())?,
420 http: management,
421 auth,
422 applications,
423 arbor,
424 catalog,
425 #[cfg(feature = "tables")]
426 tables,
427 }),
428 })
429 }
430}
431
432#[cfg(feature = "tables")]
434#[derive(Clone)]
435pub(crate) enum ScandalSelection {
436 Given(Arc<dyn Scandal>),
438 Remote(Url),
440 #[cfg(feature = "embedded")]
442 Embedded(EmbeddedScandals),
443}
444
445#[cfg(feature = "embedded")]
448type EmbeddedScandalFn = dyn Fn(&ClientWithMiddleware, Option<&TokenSource>, &Url) -> Result<Arc<dyn Scandal>, Error>
449 + Send
450 + Sync;
451
452#[cfg(feature = "embedded")]
454#[derive(Clone)]
455pub(crate) struct EmbeddedScandals(Arc<EmbeddedScandalFn>);
456
457#[cfg(feature = "tables")]
458impl ScandalSelection {
459 fn scandal(
461 &self,
462 http: &ClientWithMiddleware,
463 caller: Option<&TokenSource>,
464 auth_url: &Url,
465 api_url: &Url,
466 ) -> Result<Arc<dyn Scandal>, Error> {
467 let endpoint = match self {
468 Self::Given(scandal) => return Ok(Arc::clone(scandal)),
469 Self::Remote(endpoint) => endpoint,
470 #[cfg(feature = "embedded")]
471 Self::Embedded(scandals) => return (scandals.0)(http, caller, api_url),
472 };
473 let remote = RemoteScandal::new(endpoint.clone())?.with_http(http.clone());
474 Ok(Arc::new(match caller {
475 Some(caller) => remote.with_caller(caller.clone(), auth_url, api_url)?,
476 None => remote,
477 }))
478 }
479}
480
481#[cfg(feature = "embedded")]
483fn embedded(
484 config: &ScandalConfig,
485 http: &ClientWithMiddleware,
486 caller: Option<&TokenSource>,
487 api_url: &Url,
488) -> Result<Arc<dyn Scandal>, Error> {
489 Ok(Arc::new(match caller {
490 Some(caller) => NativeScandal::new_with_uri_resolver(
491 config,
492 Arc::new(ResourceUriResolver::new(
493 api_url.clone(),
494 caller.clone(),
495 http.clone(),
496 )),
497 )?,
498 None => NativeScandal::new(config)?,
499 }))
500}
501
502pub(crate) fn endpoint(value: &str) -> Result<Url, Error> {
503 let mut url = Url::parse(value).map_err(Error::invalid)?;
504 if !matches!(url.scheme(), "http" | "https")
505 || url.host_str().is_none()
506 || !url.username().is_empty()
507 || url.password().is_some()
508 || url.query().is_some()
509 || url.fragment().is_some()
510 {
511 return Err(Error::invalid(
512 "service endpoint must be an HTTP(S) URL without credentials, query, or fragment",
513 ));
514 }
515 if !url.path().ends_with('/') {
516 url.set_path(&format!("{}/", url.path()));
517 }
518 Ok(url)
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn builder_rejects_non_http_and_credentialed_endpoints() {
527 for endpoint in [
528 "file:///tmp/spiral",
529 "https://user@example.com",
530 "https://example.com?token=secret",
531 ] {
532 assert!(Client::builder().api_url(endpoint).build().is_err());
533 }
534 }
535
536 #[test]
537 fn builder_constructs_every_resource_client_without_native_code() {
538 let client = Client::builder()
539 .api_url("https://api.example.test/base")
540 .scandal_url("https://scandal.example.test")
541 .bearer_token("test-token")
542 .build()
543 .expect("valid SDK settings");
544 let _ = client.catalog();
545 let _ = client.locations();
546 let _ = client.tasks();
547 let _ = client.vector_indexes();
548 }
549}