Skip to main content

voltaria_sdk/
voltaria.rs

1//! Hand-written prefix-routing wrapper around the Fern-generated client.
2//!
3//! [`Voltaria`] resolves the API base URL automatically from the API key
4//! prefix, so callers don't have to pick an [`Environment`] manually:
5//!
6//! - keys starting with `live_`    -> [`Environment::Production`] (`https://api.voltaria.io`)
7//! - keys starting with `sandbox_` -> [`Environment::Sandbox`] (`https://api.sandbox.voltaria.io`)
8//! - anything else (including empty) -> [`VoltariaError::InvalidApiKey`]
9//!
10//! An explicitly supplied environment or base URL always overrides the
11//! prefix-based routing.
12//!
13//! ```no_run
14//! use voltaria_api::voltaria::Voltaria;
15//!
16//! // Routed to production from the `live_` prefix.
17//! let client = Voltaria::new("live_sk_123").expect("valid key");
18//! ```
19
20use crate::api::resources::ApiClient;
21use crate::{ApiError, ClientConfig, Environment};
22
23/// Errors produced while constructing a prefix-routed client.
24#[derive(Debug)]
25pub enum VoltariaError {
26    /// The API key did not start with a recognised environment prefix
27    /// (`live_` or `sandbox_`) and no explicit environment or base URL was
28    /// supplied to override the prefix routing.
29    InvalidApiKey,
30    /// The underlying generated client failed to build.
31    Build(ApiError),
32}
33
34impl std::fmt::Display for VoltariaError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::InvalidApiKey => write!(
38                f,
39                "invalid API key: expected a `live_` or `sandbox_` prefix, \
40                 or an explicit environment / base URL"
41            ),
42            Self::Build(e) => write!(f, "failed to build client: {e}"),
43        }
44    }
45}
46
47impl std::error::Error for VoltariaError {
48    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
49        match self {
50            Self::Build(e) => Some(e),
51            Self::InvalidApiKey => None,
52        }
53    }
54}
55
56impl From<ApiError> for VoltariaError {
57    fn from(e: ApiError) -> Self {
58        Self::Build(e)
59    }
60}
61
62/// Resolve the base URL for an API key, honouring explicit overrides.
63///
64/// Mirrors the reference behaviour exactly:
65/// 1. an explicit `environment` wins,
66/// 2. otherwise an explicit `base_url` wins,
67/// 3. otherwise the `live_` / `sandbox_` prefix selects the environment,
68/// 4. otherwise [`VoltariaError::InvalidApiKey`].
69fn resolve_base_url(
70    api_key: &str,
71    environment: Option<Environment>,
72    base_url: Option<&str>,
73) -> Result<String, VoltariaError> {
74    if let Some(env) = environment {
75        return Ok(env.url().to_string());
76    }
77    if let Some(url) = base_url {
78        return Ok(url.to_string());
79    }
80    if api_key.starts_with("live_") {
81        return Ok(Environment::Production.url().to_string());
82    }
83    if api_key.starts_with("sandbox_") {
84        return Ok(Environment::Sandbox.url().to_string());
85    }
86    Err(VoltariaError::InvalidApiKey)
87}
88
89/// Prefix-routing entry point for building a Voltaria [`ApiClient`].
90pub struct Voltaria;
91
92impl Voltaria {
93    /// Build a client, deriving the environment from the API key prefix.
94    ///
95    /// The key is also used as the bearer token on the generated client.
96    /// Returns [`VoltariaError::InvalidApiKey`] when the prefix is not
97    /// recognised.
98    pub fn new(api_key: &str) -> Result<ApiClient, VoltariaError> {
99        VoltariaBuilder::new(api_key).build()
100    }
101
102    /// Start a builder for customising overrides before construction.
103    pub fn builder(api_key: &str) -> VoltariaBuilder {
104        VoltariaBuilder::new(api_key)
105    }
106}
107
108/// Builder form that allows overriding the prefix-derived routing.
109pub struct VoltariaBuilder {
110    api_key: String,
111    environment: Option<Environment>,
112    base_url: Option<String>,
113}
114
115impl VoltariaBuilder {
116    /// Create a builder for the given API key.
117    pub fn new(api_key: impl Into<String>) -> Self {
118        Self {
119            api_key: api_key.into(),
120            environment: None,
121            base_url: None,
122        }
123    }
124
125    /// Force a specific environment, overriding the key prefix.
126    pub fn environment(mut self, environment: Environment) -> Self {
127        self.environment = Some(environment);
128        self
129    }
130
131    /// Force a specific base URL, overriding the key prefix.
132    ///
133    /// An explicit environment, if also set, takes precedence over this.
134    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
135        self.base_url = Some(base_url.into());
136        self
137    }
138
139    /// Resolve the routing and build the generated client.
140    pub fn build(self) -> Result<ApiClient, VoltariaError> {
141        let base_url =
142            resolve_base_url(&self.api_key, self.environment, self.base_url.as_deref())?;
143        let config = ClientConfig {
144            base_url,
145            token: Some(self.api_key),
146            ..Default::default()
147        };
148        Ok(ApiClient::new(config)?)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    const PROD_URL: &str = "https://api.voltaria.io";
157    const SANDBOX_URL: &str = "https://api.sandbox.voltaria.io";
158
159    #[test]
160    fn live_prefix_routes_to_production() {
161        let client = Voltaria::new("live_abc123").expect("live key should build");
162        assert_eq!(client.config.base_url, PROD_URL);
163        assert_eq!(client.config.token.as_deref(), Some("live_abc123"));
164    }
165
166    #[test]
167    fn sandbox_prefix_routes_to_sandbox() {
168        let client = Voltaria::new("sandbox_abc123").expect("sandbox key should build");
169        assert_eq!(client.config.base_url, SANDBOX_URL);
170        assert_eq!(client.config.token.as_deref(), Some("sandbox_abc123"));
171    }
172
173    #[test]
174    fn unknown_prefix_is_invalid() {
175        // `ApiClient` is not `Debug`, so match on the result rather than
176        // using `expect_err` (which would require `Debug` on the Ok type).
177        match Voltaria::new("nope_abc123") {
178            Err(VoltariaError::InvalidApiKey) => {}
179            Err(other) => panic!("expected InvalidApiKey, got {other:?}"),
180            Ok(_) => panic!("expected InvalidApiKey, got Ok"),
181        }
182    }
183
184    #[test]
185    fn empty_key_is_invalid() {
186        match Voltaria::new("") {
187            Err(VoltariaError::InvalidApiKey) => {}
188            Err(other) => panic!("expected InvalidApiKey, got {other:?}"),
189            Ok(_) => panic!("expected InvalidApiKey, got Ok"),
190        }
191    }
192
193    #[test]
194    fn explicit_base_url_overrides_prefix() {
195        // Unknown prefix would normally error, but base_url override rescues it.
196        let client = Voltaria::builder("nope_abc123")
197            .base_url("https://localhost:8000")
198            .build()
199            .expect("base_url override should build");
200        assert_eq!(client.config.base_url, "https://localhost:8000");
201    }
202
203    #[test]
204    fn explicit_environment_overrides_prefix() {
205        // live_ prefix would route to production, but the env override wins.
206        let client = Voltaria::builder("live_abc123")
207            .environment(Environment::Sandbox)
208            .build()
209            .expect("environment override should build");
210        assert_eq!(client.config.base_url, SANDBOX_URL);
211    }
212
213    #[test]
214    fn environment_takes_precedence_over_base_url() {
215        let client = Voltaria::builder("sandbox_abc123")
216            .base_url("https://localhost:8000")
217            .environment(Environment::Production)
218            .build()
219            .expect("environment should win over base_url");
220        assert_eq!(client.config.base_url, PROD_URL);
221    }
222}