use std::fmt::{self, Debug, Formatter};
use std::sync::Arc;
use reqwest::Url;
use reqwest_middleware::ClientWithMiddleware;
#[cfg(feature = "tables")]
use uuid::Uuid;
use crate::auth::{
CredentialChain, OidcAuthProvider, Token, TokenSource, default_auth_url, token_endpoint,
};
#[cfg(feature = "private-key-auth")]
use crate::auth::{PrivateKeyCredentials, PrivateKeyJwtProvider};
use crate::http::{HttpClient, default_http_client};
use crate::resources::applications::{
ApplicationService, ApplicationsClient, ExecutionSettings, InstallationsClient,
clients as application_clients,
};
use crate::resources::arbor::Client as ArborClient;
use crate::resources::catalog::Client as CatalogClient;
use crate::resources::connection::HttpExternalConnectionClient;
use crate::resources::location::HttpLocationClient;
use crate::resources::organization::{Organization, OrganizationAdminClient};
use crate::resources::project::{AuthorizationClient, ProjectClient};
use crate::resources::task::HttpTaskClient;
use crate::resources::vector_index::HttpVectorIndexClient;
use crate::resources::workload::WorkloadClient;
#[cfg(feature = "tables")]
use crate::scandal::Scandal;
#[cfg(feature = "tables")]
use crate::scandal_backend::ScandalBackend;
#[cfg(feature = "tables")]
use crate::table::{Table, TableIdentity};
use crate::{Config, Error};
#[cfg(feature = "tables")]
use crate::{Functions, RemoteScandal};
#[cfg(feature = "embedded")]
use crate::{NativeScandal, ResourceUriResolver, ScandalConfig};
pub(crate) struct Connection {
http: HttpClient,
projects: ProjectClient,
organizations: Organization,
workloads: WorkloadClient,
authorization: AuthorizationClient,
pub(crate) locations: HttpLocationClient,
connections: HttpExternalConnectionClient,
pub(crate) tasks: HttpTaskClient,
pub(crate) vector_indexes: HttpVectorIndexClient,
auth: Option<TokenSource>,
applications: Arc<ApplicationService>,
pub(crate) arbor: ArborClient,
pub(crate) catalog: CatalogClient,
#[cfg(feature = "tables")]
tables: Option<Arc<ScandalBackend>>,
}
impl Connection {
#[cfg(feature = "tables")]
pub(crate) fn tables(&self) -> Result<&ScandalBackend, Error> {
self.tables.as_deref().ok_or_else(|| {
Error::invalid("no scandal selected; configure Client::builder().scandal(...)")
})
}
}
#[derive(Clone)]
pub struct Client {
pub(crate) connection: Arc<Connection>,
}
impl Debug for Client {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.debug_struct("Client").finish_non_exhaustive()
}
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub async fn from_env() -> Result<Self, Error> {
Self::from_config(&Config::load().await?)
}
pub fn from_config(config: &Config) -> Result<Self, Error> {
Self::builder().config(config).build()
}
pub fn http(&self) -> &HttpClient {
&self.connection.http
}
pub async fn access_token(&self) -> Result<Token, Error> {
self.connection
.auth
.as_ref()
.ok_or(Error::CredentialsNotFound)?
.token()
.await?
.ok_or(Error::CredentialsNotFound)
}
pub fn projects(&self) -> &ProjectClient {
&self.connection.projects
}
pub fn authorization(&self) -> &AuthorizationClient {
&self.connection.authorization
}
pub fn organization(&self) -> &Organization {
&self.connection.organizations
}
pub fn organization_admin(&self) -> &OrganizationAdminClient {
&self.connection.organizations
}
pub fn workloads(&self) -> &WorkloadClient {
&self.connection.workloads
}
pub fn applications(&self) -> ApplicationsClient {
ApplicationsClient {
service: self.connection.applications.clone(),
}
}
pub fn installations(&self) -> InstallationsClient {
InstallationsClient {
service: self.connection.applications.clone(),
connection: self.clone(),
}
}
pub fn tables(&self) -> &ArborClient {
&self.connection.arbor
}
pub fn catalog(&self) -> &CatalogClient {
&self.connection.catalog
}
pub fn locations(&self) -> &HttpLocationClient {
&self.connection.locations
}
pub fn connections(&self) -> &HttpExternalConnectionClient {
&self.connection.connections
}
pub fn tasks(&self) -> &HttpTaskClient {
&self.connection.tasks
}
pub fn vector_indexes(&self) -> &HttpVectorIndexClient {
&self.connection.vector_indexes
}
#[cfg(feature = "tables")]
pub async fn functions(&self) -> Result<Functions, Error> {
Functions::new(self.connection.tables()?.scandal()).await
}
#[cfg(feature = "tables")]
pub fn table_by_id(&self, resource_id: Uuid, version_id: Uuid) -> Table {
Table::new(
Arc::clone(&self.connection),
TableIdentity {
resource_id,
version_id,
},
)
}
}
#[derive(Default)]
pub struct ClientBuilder {
api_url: Option<String>,
auth_url: Option<String>,
scandal_url: Option<String>,
authentication: Option<ClientAuthentication>,
http: Option<ClientWithMiddleware>,
#[cfg(feature = "tables")]
scandal: Option<Arc<dyn Scandal>>,
#[cfg(feature = "embedded")]
embedded: Option<EmbeddedScandals>,
}
enum ClientAuthentication {
Provider(TokenSource),
#[cfg(feature = "private-key-auth")]
PrivateKey(PrivateKeyCredentials),
TrustedIssuer(String, TokenSource),
}
impl ClientBuilder {
pub fn config(mut self, config: &Config) -> Self {
self.api_url = Some(config.api_url.to_string());
self.auth_url = Some(config.auth_url.to_string());
self.scandal_url = config.scandal_url.as_ref().map(ToString::to_string);
self.authentication = Some(ClientAuthentication::Provider(config.auth.clone()));
self.http = Some(config.http.clone());
self
}
pub fn api_url(mut self, url: impl Into<String>) -> Self {
self.api_url = Some(url.into());
self
}
pub fn auth_url(mut self, url: impl Into<String>) -> Self {
self.auth_url = Some(url.into());
self
}
#[cfg(feature = "private-key-auth")]
pub fn private_key(mut self, client_id: impl Into<String>, jwk: &str) -> Result<Self, Error> {
self.authentication = Some(ClientAuthentication::PrivateKey(
PrivateKeyCredentials::from_jwk(client_id.into(), jwk)?,
));
Ok(self)
}
pub fn trusted_issuer(mut self, client_id: impl Into<String>, source: TokenSource) -> Self {
self.authentication = Some(ClientAuthentication::TrustedIssuer(
client_id.into(),
source,
));
self
}
pub fn scandal_url(mut self, url: impl Into<String>) -> Self {
self.scandal_url = Some(url.into());
self
}
pub fn http_client(mut self, http: impl Into<ClientWithMiddleware>) -> Self {
self.http = Some(http.into());
self
}
pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
self.authentication = Some(ClientAuthentication::Provider(TokenSource::fixed(
token.into().into(),
)));
self
}
pub fn token_source(mut self, provider: TokenSource) -> Self {
self.authentication = Some(ClientAuthentication::Provider(provider));
self
}
#[cfg(feature = "tables")]
pub fn scandal(mut self, scandal: Arc<dyn Scandal>) -> Self {
self.scandal = Some(scandal);
self
}
#[cfg(feature = "embedded")]
pub fn embedded_scandal(mut self, config: ScandalConfig) -> Self {
self.embedded = Some(EmbeddedScandals(Arc::new(move |http, caller, api_url| {
embedded(&config, http, caller, api_url)
})));
self
}
#[cfg(feature = "embedded")]
pub(crate) fn embedded_scandals(mut self, scandals: EmbeddedScandals) -> Self {
self.embedded = Some(scandals);
self
}
pub fn build(self) -> Result<Client, Error> {
let api = endpoint(
self.api_url
.as_deref()
.ok_or_else(|| Error::invalid("api_url is required"))?,
)?;
let http = self
.http
.unwrap_or_else(|| default_http_client().expect("valid HTTP defaults"));
let auth_url = self
.auth_url
.as_deref()
.map(endpoint)
.transpose()?
.unwrap_or_else(default_auth_url);
let auth: Option<TokenSource> = match self.authentication {
None => None,
Some(ClientAuthentication::Provider(provider)) => Some(provider),
#[cfg(feature = "private-key-auth")]
Some(ClientAuthentication::PrivateKey(credentials)) => Some(
CredentialChain::new(vec![
PrivateKeyJwtProvider::new(http.clone(), &auth_url)?
.with_credentials(credentials)
.into(),
])
.into(),
),
Some(ClientAuthentication::TrustedIssuer(id, source)) => Some(
CredentialChain::new(vec![
OidcAuthProvider::new(http.clone(), token_endpoint(&auth_url)?, id)
.with_assertion_provider(source)
.into(),
])
.into(),
),
};
let management = HttpClient::new(api.clone(), http.clone(), auth.clone());
#[cfg(feature = "tables")]
let mut selections = Vec::new();
#[cfg(feature = "tables")]
{
selections.extend(self.scandal.map(ScandalSelection::Given));
if let Some(url) = self.scandal_url {
selections.push(ScandalSelection::Remote(endpoint(&url)?));
}
}
#[cfg(feature = "embedded")]
selections.extend(self.embedded.map(ScandalSelection::Embedded));
#[cfg(feature = "tables")]
let selection = match selections.len() {
0 | 1 => selections.pop(),
_ => {
return Err(Error::invalid(
"select one of a scandal, a scandal endpoint or an embedded scandal",
));
}
};
#[cfg(feature = "tables")]
let scandal = selection
.as_ref()
.map(|selection| selection.scandal(&http, auth.as_ref(), &auth_url, &api))
.transpose()?;
let applications = application_clients(
api.as_str(),
auth_url,
http,
auth.clone(),
ExecutionSettings {
#[cfg(feature = "tables")]
scandal: selection,
},
)?;
let arbor = ArborClient::from_http(management.clone())?;
let catalog = CatalogClient::from_http(management.clone())?;
#[cfg(feature = "tables")]
let tables = scandal.map(|scandal| Arc::new(ScandalBackend::new(scandal, arbor.clone())));
Ok(Client {
connection: Arc::new(Connection {
projects: ProjectClient::from_http(management.clone())?,
organizations: Organization::from_http(management.clone())?,
workloads: WorkloadClient::from_http(management.clone())?,
authorization: AuthorizationClient::from_http(management.clone())?,
locations: HttpLocationClient::from_http(management.clone())?,
connections: HttpExternalConnectionClient::from_http(management.clone())?,
tasks: HttpTaskClient::from_http(management.clone())?,
vector_indexes: HttpVectorIndexClient::from_http(management.clone())?,
http: management,
auth,
applications,
arbor,
catalog,
#[cfg(feature = "tables")]
tables,
}),
})
}
}
#[cfg(feature = "tables")]
#[derive(Clone)]
pub(crate) enum ScandalSelection {
Given(Arc<dyn Scandal>),
Remote(Url),
#[cfg(feature = "embedded")]
Embedded(EmbeddedScandals),
}
#[cfg(feature = "embedded")]
type EmbeddedScandalFn = dyn Fn(&ClientWithMiddleware, Option<&TokenSource>, &Url) -> Result<Arc<dyn Scandal>, Error>
+ Send
+ Sync;
#[cfg(feature = "embedded")]
#[derive(Clone)]
pub(crate) struct EmbeddedScandals(Arc<EmbeddedScandalFn>);
#[cfg(feature = "tables")]
impl ScandalSelection {
fn scandal(
&self,
http: &ClientWithMiddleware,
caller: Option<&TokenSource>,
auth_url: &Url,
api_url: &Url,
) -> Result<Arc<dyn Scandal>, Error> {
let endpoint = match self {
Self::Given(scandal) => return Ok(Arc::clone(scandal)),
Self::Remote(endpoint) => endpoint,
#[cfg(feature = "embedded")]
Self::Embedded(scandals) => return (scandals.0)(http, caller, api_url),
};
let remote = RemoteScandal::new(endpoint.clone())?.with_http(http.clone());
Ok(Arc::new(match caller {
Some(caller) => remote.with_caller(caller.clone(), auth_url, api_url)?,
None => remote,
}))
}
}
#[cfg(feature = "embedded")]
fn embedded(
config: &ScandalConfig,
http: &ClientWithMiddleware,
caller: Option<&TokenSource>,
api_url: &Url,
) -> Result<Arc<dyn Scandal>, Error> {
Ok(Arc::new(match caller {
Some(caller) => NativeScandal::new_with_uri_resolver(
config,
Arc::new(ResourceUriResolver::new(
api_url.clone(),
caller.clone(),
http.clone(),
)),
)?,
None => NativeScandal::new(config)?,
}))
}
pub(crate) fn endpoint(value: &str) -> Result<Url, Error> {
let mut url = Url::parse(value).map_err(Error::invalid)?;
if !matches!(url.scheme(), "http" | "https")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(Error::invalid(
"service endpoint must be an HTTP(S) URL without credentials, query, or fragment",
));
}
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
Ok(url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_rejects_non_http_and_credentialed_endpoints() {
for endpoint in [
"file:///tmp/spiral",
"https://user@example.com",
"https://example.com?token=secret",
] {
assert!(Client::builder().api_url(endpoint).build().is_err());
}
}
#[test]
fn builder_constructs_every_resource_client_without_native_code() {
let client = Client::builder()
.api_url("https://api.example.test/base")
.scandal_url("https://scandal.example.test")
.bearer_token("test-token")
.build()
.expect("valid SDK settings");
let _ = client.catalog();
let _ = client.locations();
let _ = client.tasks();
let _ = client.vector_indexes();
}
}