spiral-sdk 0.1.7

Rust SDK for Spiral resource and table APIs
Documentation
//! Client configuration and credential discovery.
//!
//! The SDK reads no settings file. Applications, such as the CLI, read their own
//! configuration and pass endpoints and credentials to [`ConfigBuilder`].

use std::time::Duration;

use reqwest::redirect::Policy;
use reqwest::{Client as ReqwestClient, Url};

use crate::Error;
use crate::auth::{TokenSource, default_auth_url, default_credentials};
use crate::client::endpoint;
use crate::http::http_client_builder;

/// The production control-plane endpoint.
pub const DEFAULT_API_URL: &str = "https://api.spiraldb.dev";
/// The production Spiral file-system endpoint, for the embedded runtime.
pub const DEFAULT_SPFS_URL: &str = "https://spfs.spiraldb.dev";

/// Resolved endpoints, HTTP pool and credentials shared by SDK clients.
#[derive(Clone)]
pub struct Config {
    pub(crate) api_url: Url,
    pub(crate) auth_url: Url,
    pub(crate) scandal_url: Option<Url>,
    pub(crate) auth: TokenSource,
    pub(crate) http: ReqwestClient,
}

impl Config {
    /// The default endpoints and the noninteractive default credential chain.
    pub async fn load() -> Result<Self, Error> {
        Self::builder().load().await
    }

    /// Configure endpoints and credentials.
    pub fn builder() -> ConfigBuilder {
        ConfigBuilder::default()
    }

    /// The resolved control-plane endpoint, including the token's resource when discovered.
    pub fn api_url(&self) -> &Url {
        &self.api_url
    }

    /// The remote scandal endpoint, when configured; Table operations then run there.
    pub fn scandal_url(&self) -> Option<&Url> {
        self.scandal_url.as_ref()
    }

    /// The shared credential provider, consulted before each authenticated request.
    pub fn token_source(&self) -> &TokenSource {
        &self.auth
    }
}

/// Endpoints and credentials for SDK clients. Unset endpoints use the production defaults.
#[derive(Default)]
pub struct ConfigBuilder {
    api_url: Option<String>,
    scandal_url: Option<String>,
    auth_url: Option<String>,
    auth: Option<TokenSource>,
    http: Option<ReqwestClient>,
    fallback: Option<TokenSource>,
}

impl ConfigBuilder {
    /// Set the API endpoint explicitly; credential discovery cannot replace it.
    pub fn api_url(mut self, url: impl Into<String>) -> Self {
        self.api_url = Some(url.into());
        self
    }

    /// Select remote scandal at this endpoint.
    pub fn scandal_url(mut self, url: impl Into<String>) -> Self {
        self.scandal_url = Some(url.into());
        self
    }

    /// Set the central authorization server used by discovered credentials.
    pub fn auth_url(mut self, url: impl Into<String>) -> Self {
        self.auth_url = Some(url.into());
        self
    }

    /// Use caller-owned credentials instead of the default credential chain.
    pub fn token_source(mut self, provider: TokenSource) -> Self {
        self.auth = Some(provider);
        self
    }

    /// Use a fixed bearer credential instead of discovered credentials.
    pub fn bearer_token(self, token: impl Into<String>) -> Self {
        self.token_source(TokenSource::fixed(token.into().into()))
    }

    /// Share a caller-owned HTTP pool across the resource clients.
    ///
    /// Disable redirects on this pool to protect bearer credentials.
    pub fn http_client(mut self, http: ReqwestClient) -> Self {
        self.http = Some(http);
        self
    }

    /// Add a final credential source after the SDK's environment and workload providers.
    ///
    /// The caller controls this provider's refresh and interactive behavior.
    pub fn fallback_token_source(mut self, provider: TokenSource) -> Self {
        self.fallback = Some(provider);
        self
    }

    /// Resolve credentials and discover the API endpoint before constructing clients.
    ///
    /// Without a caller-owned provider, the default credential chain considers `SPIRAL_TOKEN`,
    /// an environment private key, a legacy secret, workload identities and an optional caller
    /// fallback, in that order. Errors stop discovery. Without an explicit API endpoint, the
    /// client uses the endpoint returned with the credential.
    pub async fn load(self) -> Result<Config, Error> {
        let api_explicit = self.api_url.is_some();
        let mut api_url = endpoint(self.api_url.as_deref().unwrap_or(DEFAULT_API_URL))?;
        let scandal_url = self.scandal_url.as_deref().map(endpoint).transpose()?;
        let auth_url = match self.auth_url.as_deref() {
            Some(url) => endpoint(url)?,
            None => default_auth_url(),
        };
        let auth = match self.auth {
            Some(auth) => auth,
            None => default_credentials(&api_url, &auth_url, self.fallback)?,
        };
        let token = auth.token().await?.ok_or(Error::CredentialsNotFound)?;
        if !api_explicit && let Some(resource) = token.resource() {
            api_url = endpoint(resource)?;
        }
        let http = match self.http {
            Some(http) => http,
            None => http_client_builder()
                .redirect(Policy::none())
                .connect_timeout(Duration::from_secs(10))
                .timeout(Duration::from_secs(60))
                .build()?,
        };
        Ok(Config {
            api_url,
            auth_url,
            scandal_url,
            auth,
            http,
        })
    }
}