Skip to main content

spiral/
client.rs

1//! Shared transport and authentication for resource clients.
2
3use 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    /// Table execution through the selected scandal.
62    #[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/// A shared Spiral connection with one transport and credential provider.
71#[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    /// Configure the control-plane endpoints and the scandal that runs Table operations.
84    pub fn builder() -> ClientBuilder {
85        ClientBuilder::default()
86    }
87
88    /// The default endpoints and the noninteractive default credential chain.
89    pub async fn from_env() -> Result<Self, Error> {
90        Self::from_config(&Config::load().await?)
91    }
92
93    /// Share a resolved configuration's HTTP pool and credential provider.
94    pub fn from_config(config: &Config) -> Result<Self, Error> {
95        Self::builder().config(config).build()
96    }
97
98    /// Shared authenticated transport for HTTP operations without a typed SDK client.
99    pub fn http(&self) -> &HttpClient {
100        &self.connection.http
101    }
102
103    /// Resolve the current credential without changing the selected identity.
104    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    /// Project lifecycle and shared presentation state.
115    pub fn projects(&self) -> &ProjectClient {
116        &self.connection.projects
117    }
118
119    /// Access grants and role management through the shared authenticated connection.
120    pub fn authorization(&self) -> &AuthorizationClient {
121        &self.connection.authorization
122    }
123
124    /// Organizations, memberships, users, invitations and settings.
125    pub fn organization(&self) -> &Organization {
126        &self.connection.organizations
127    }
128
129    /// Platform administration of organization synchronization and lifecycle.
130    pub fn organization_admin(&self) -> &OrganizationAdminClient {
131        &self.connection.organizations
132    }
133
134    /// Workload identities, public keys and trust policies.
135    pub fn workloads(&self) -> &WorkloadClient {
136        &self.connection.workloads
137    }
138
139    /// Application registrations, scopes and organization app-access policy.
140    pub fn applications(&self) -> ApplicationsClient {
141        ApplicationsClient {
142            service: self.connection.applications.clone(),
143        }
144    }
145
146    /// Organization application installations and their approval lifecycle.
147    pub fn installations(&self) -> InstallationsClient {
148        InstallationsClient {
149            service: self.connection.applications.clone(),
150            connection: self.clone(),
151        }
152    }
153
154    /// Low-level table resource client for tools and integrations.
155    pub fn tables(&self) -> &ArborClient {
156        &self.connection.arbor
157    }
158
159    /// Project catalog client.
160    pub fn catalog(&self) -> &CatalogClient {
161        &self.connection.catalog
162    }
163
164    /// Location resource client.
165    pub fn locations(&self) -> &HttpLocationClient {
166        &self.connection.locations
167    }
168
169    /// Connection service client for credentials, metadata and connectivity checks.
170    pub fn connections(&self) -> &HttpExternalConnectionClient {
171        &self.connection.connections
172    }
173
174    /// Task resource client.
175    pub fn tasks(&self) -> &HttpTaskClient {
176        &self.connection.tasks
177    }
178
179    /// Vector-index resource client.
180    pub fn vector_indexes(&self) -> &HttpVectorIndexClient {
181        &self.connection.vector_indexes
182    }
183
184    /// The SPQL functions of the scandal that runs this client's Table operations.
185    #[cfg(feature = "tables")]
186    pub async fn functions(&self) -> Result<Functions, Error> {
187        Functions::new(self.connection.tables()?.scandal()).await
188    }
189
190    /// Open an exact table version by IDs for tools and integrations.
191    #[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/// Settings shared by generated resource clients and SDK orchestration.
204#[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    /// Share resolved settings, endpoints, credentials and the HTTP pool.
226    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    /// Set the control-plane endpoint.
236    pub fn api_url(mut self, url: impl Into<String>) -> Self {
237        self.api_url = Some(url.into());
238        self
239    }
240
241    /// Set the authorization server for app and installation token exchanges.
242    pub fn auth_url(mut self, url: impl Into<String>) -> Self {
243        self.auth_url = Some(url.into());
244        self
245    }
246
247    /// Authenticate with a registered private JWK, renewing tokens automatically.
248    #[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    /// Exchange fresh trusted-issuer assertions for this client's renewable tokens.
257    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    /// Execute table operations through remote scandal at this endpoint.
266    ///
267    /// With credentials, every request carries a scandal capability exchanged from this
268    /// client's access token at the authorization server; each identity the client acts as,
269    /// such as an installation, obtains its own.
270    pub fn scandal_url(mut self, url: impl Into<String>) -> Self {
271        self.scandal_url = Some(url.into());
272        self
273    }
274
275    /// Use one redirect-disabled HTTP pool for all clients.
276    /// Configure credentials through the builder independently of this pool.
277    pub fn http_client(mut self, http: impl Into<ClientWithMiddleware>) -> Self {
278        self.http = Some(http.into());
279        self
280    }
281
282    /// Authenticate with a fixed bearer credential.
283    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    /// Resolve the current credential immediately before every request.
291    ///
292    /// The provider renews one identity's credential: table reads reuse the storage access that
293    /// earlier reads resolved with it. Build another client to act as another identity.
294    pub fn token_source(mut self, provider: TokenSource) -> Self {
295        self.authentication = Some(ClientAuthentication::Provider(provider));
296        self
297    }
298
299    /// Execute table operations through `scandal`, embedded or remote.
300    ///
301    /// The client resolves storage bindings with its own identity, so the same scandal serves
302    /// an installation authenticated later. A [`RemoteScandal`] given here calls as whichever
303    /// caller it was built with; [`scandal_url`](Self::scandal_url) gives each identity its own.
304    #[cfg(feature = "tables")]
305    pub fn scandal(mut self, scandal: Arc<dyn Scandal>) -> Self {
306        self.scandal = Some(scandal);
307        self
308    }
309
310    /// Execute table operations in an embedded [`NativeScandal`] with the storage transports
311    /// `config` allows. It binds the `spiral://` addresses that rows name, such as a file under
312    /// a Location, as this client's identity, and each identity the client acts as, such as an
313    /// installation, gets its own. Build the client inside a Tokio runtime.
314    #[cfg(feature = "embedded")]
315    pub fn embedded_scandal(mut self, config: ScandalConfig) -> Self {
316        // Only a caller of this method reaches the native library, so a binary that never
317        // selects an embedded scandal links without it.
318        self.embedded = Some(EmbeddedScandals(Arc::new(move |http, caller, api_url| {
319            embedded(&config, http, caller, api_url)
320        })));
321        self
322    }
323
324    /// Execute table operations in embedded scandals that `scandals` builds.
325    #[cfg(feature = "embedded")]
326    pub(crate) fn embedded_scandals(mut self, scandals: EmbeddedScandals) -> Self {
327        self.embedded = Some(scandals);
328        self
329    }
330
331    /// Build all resource clients and the streaming table client.
332    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/// The scandal a client was configured with, which each identity it acts as calls.
433#[cfg(feature = "tables")]
434#[derive(Clone)]
435pub(crate) enum ScandalSelection {
436    /// A scandal the application built, shared by every identity as it is.
437    Given(Arc<dyn Scandal>),
438    /// Remote scandal at this endpoint, which each identity calls with its own capability.
439    Remote(Url),
440    /// An embedded scandal for each identity, which resolves addresses as that identity.
441    #[cfg(feature = "embedded")]
442    Embedded(EmbeddedScandals),
443}
444
445/// Builds an embedded scandal for one identity: from the HTTP pool, the identity's credentials
446/// when it has any, and the API its addresses resolve through.
447#[cfg(feature = "embedded")]
448type EmbeddedScandalFn = dyn Fn(&ClientWithMiddleware, Option<&TokenSource>, &Url) -> Result<Arc<dyn Scandal>, Error>
449    + Send
450    + Sync;
451
452/// The embedded scandals a client builds, one for each identity it acts as.
453#[cfg(feature = "embedded")]
454#[derive(Clone)]
455pub(crate) struct EmbeddedScandals(Arc<EmbeddedScandalFn>);
456
457#[cfg(feature = "tables")]
458impl ScandalSelection {
459    /// The scandal for `caller`'s requests.
460    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/// An embedded scandal that resolves the addresses rows name as `caller`, when there is one.
482#[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}