Skip to main content

spiral/
config.rs

1//! Client configuration and credential discovery.
2//!
3//! The SDK reads no settings file. Applications, such as the CLI, read their own
4//! configuration and pass endpoints and credentials to [`ConfigBuilder`].
5
6use reqwest::Url;
7use reqwest_middleware::ClientWithMiddleware as ReqwestClient;
8
9use crate::Error;
10use crate::auth::{TokenSource, default_auth_url, default_credentials};
11use crate::client::endpoint;
12use crate::http::default_http_client;
13
14/// The production control-plane endpoint.
15pub const DEFAULT_API_URL: &str = "https://api.spiraldb.dev";
16/// The production Spiral file-system endpoint, for the embedded runtime.
17pub const DEFAULT_SPFS_URL: &str = "https://spfs.spiraldb.dev";
18
19/// Resolved endpoints, HTTP pool and credentials shared by SDK clients.
20#[derive(Clone)]
21pub struct Config {
22    pub(crate) api_url: Url,
23    pub(crate) auth_url: Url,
24    pub(crate) scandal_url: Option<Url>,
25    pub(crate) auth: TokenSource,
26    pub(crate) http: ReqwestClient,
27}
28
29impl Config {
30    /// The default endpoints and the noninteractive default credential chain.
31    pub async fn load() -> Result<Self, Error> {
32        Self::builder().load().await
33    }
34
35    /// Configure endpoints and credentials.
36    pub fn builder() -> ConfigBuilder {
37        ConfigBuilder::default()
38    }
39
40    /// The resolved control-plane endpoint, including the token's resource when discovered.
41    pub fn api_url(&self) -> &Url {
42        &self.api_url
43    }
44
45    /// The remote scandal endpoint, when configured; Table operations then run there.
46    pub fn scandal_url(&self) -> Option<&Url> {
47        self.scandal_url.as_ref()
48    }
49
50    /// The shared credential provider, consulted before each authenticated request.
51    pub fn token_source(&self) -> &TokenSource {
52        &self.auth
53    }
54}
55
56/// Endpoints and credentials for SDK clients. Unset endpoints use the production defaults.
57#[derive(Default)]
58pub struct ConfigBuilder {
59    api_url: Option<String>,
60    scandal_url: Option<String>,
61    auth_url: Option<String>,
62    auth: Option<TokenSource>,
63    http: Option<ReqwestClient>,
64    fallback: Option<TokenSource>,
65}
66
67impl ConfigBuilder {
68    /// Set the API endpoint explicitly; credential discovery cannot replace it.
69    pub fn api_url(mut self, url: impl Into<String>) -> Self {
70        self.api_url = Some(url.into());
71        self
72    }
73
74    /// Select remote scandal at this endpoint.
75    pub fn scandal_url(mut self, url: impl Into<String>) -> Self {
76        self.scandal_url = Some(url.into());
77        self
78    }
79
80    /// Set the central authorization server used by discovered credentials.
81    pub fn auth_url(mut self, url: impl Into<String>) -> Self {
82        self.auth_url = Some(url.into());
83        self
84    }
85
86    /// Use caller-owned credentials instead of the default credential chain.
87    pub fn token_source(mut self, provider: TokenSource) -> Self {
88        self.auth = Some(provider);
89        self
90    }
91
92    /// Use a fixed bearer credential instead of discovered credentials.
93    pub fn bearer_token(self, token: impl Into<String>) -> Self {
94        self.token_source(TokenSource::fixed(token.into().into()))
95    }
96
97    /// Share a caller-owned HTTP pool across the resource clients.
98    ///
99    /// Disable redirects on this pool to protect bearer credentials.
100    pub fn http_client(mut self, http: impl Into<ReqwestClient>) -> Self {
101        self.http = Some(http.into());
102        self
103    }
104
105    /// Add a final credential source after the SDK's environment and workload providers.
106    ///
107    /// The caller controls this provider's refresh and interactive behavior.
108    pub fn fallback_token_source(mut self, provider: TokenSource) -> Self {
109        self.fallback = Some(provider);
110        self
111    }
112
113    /// Resolve credentials and discover the API endpoint before constructing clients.
114    ///
115    /// Without a caller-owned provider, the default credential chain considers `SPIRAL_TOKEN`,
116    /// an environment private key, a legacy secret, workload identities and an optional caller
117    /// fallback, in that order. Errors stop discovery. Without an explicit API endpoint, the
118    /// client uses the endpoint returned with the credential.
119    pub async fn load(self) -> Result<Config, Error> {
120        let api_explicit = self.api_url.is_some();
121        let mut api_url = endpoint(self.api_url.as_deref().unwrap_or(DEFAULT_API_URL))?;
122        let scandal_url = self.scandal_url.as_deref().map(endpoint).transpose()?;
123        let auth_url = match self.auth_url.as_deref() {
124            Some(url) => endpoint(url)?,
125            None => default_auth_url(),
126        };
127        let http = match self.http {
128            Some(http) => http,
129            None => default_http_client()?,
130        };
131        let auth = match self.auth {
132            Some(auth) => auth,
133            None => default_credentials(http.clone(), &api_url, &auth_url, self.fallback)?,
134        };
135        let token = auth.token().await?.ok_or(Error::CredentialsNotFound)?;
136        if !api_explicit && let Some(resource) = token.resource() {
137            api_url = endpoint(resource)?;
138        }
139        Ok(Config {
140            api_url,
141            auth_url,
142            scandal_url,
143            auth,
144            http,
145        })
146    }
147}