Skip to main content

scconfig_rs/
bootstrap.rs

1use std::{env, time::Duration};
2
3use serde::de::DeserializeOwned;
4
5use crate::{Environment, EnvironmentRequest, Error, Result, SpringConfigClient};
6
7/// Environment-driven bootstrap settings for Spring Cloud Config Server.
8///
9/// This is intended for real service startup paths where configuration should be
10/// loaded from environment variables instead of hard-coded values.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct BootstrapConfig {
13    server_url: String,
14    application: String,
15    profiles: Vec<String>,
16    label: Option<String>,
17    username: Option<String>,
18    password: Option<String>,
19    bearer_token: Option<String>,
20    accept_invalid_tls: bool,
21    timeout: Option<Duration>,
22}
23
24impl BootstrapConfig {
25    /// Environment variable used for the Config Server base URL.
26    pub const SERVER_URL_ENV: &'static str = "SPRING_CONFIG_SERVER_URL";
27    /// Environment variable used for the application name.
28    pub const APPLICATION_ENV: &'static str = "SPRING_APPLICATION_NAME";
29    /// Environment variable used for the active profile list.
30    pub const PROFILES_ENV: &'static str = "SPRING_PROFILES_ACTIVE";
31    /// Environment variable used for the config label.
32    pub const LABEL_ENV: &'static str = "SPRING_CONFIG_LABEL";
33    /// Environment variable used for the Config Server username.
34    pub const USERNAME_ENV: &'static str = "SPRING_CONFIG_USERNAME";
35    /// Environment variable used for the Config Server password.
36    pub const PASSWORD_ENV: &'static str = "SPRING_CONFIG_PASSWORD";
37    /// Environment variable used for a Config Server bearer token.
38    pub const BEARER_TOKEN_ENV: &'static str = "SPRING_CONFIG_BEARER_TOKEN";
39    /// Environment variable used to disable TLS certificate and hostname validation.
40    pub const INSECURE_TLS_ENV: &'static str = "SPRING_CONFIG_INSECURE_TLS";
41    /// Environment variable used for request timeout in seconds.
42    pub const TIMEOUT_SECONDS_ENV: &'static str = "SPRING_CONFIG_TIMEOUT_SECS";
43
44    /// Creates a bootstrap configuration explicitly.
45    pub fn new<A, S, I, P>(server_url: A, application: S, profiles: I) -> Result<Self>
46    where
47        A: Into<String>,
48        S: Into<String>,
49        I: IntoIterator<Item = P>,
50        P: Into<String>,
51    {
52        Ok(Self {
53            server_url: server_url.into().trim().to_string(),
54            application: application.into().trim().to_string(),
55            profiles: profiles
56                .into_iter()
57                .map(Into::into)
58                .map(|value| value.trim().to_string())
59                .filter(|value| !value.is_empty())
60                .collect(),
61            label: None,
62            username: None,
63            password: None,
64            bearer_token: None,
65            accept_invalid_tls: false,
66            timeout: None,
67        }
68        .validate()?)
69    }
70
71    /// Builds a bootstrap configuration from environment variables.
72    ///
73    /// Required:
74    /// - `SPRING_CONFIG_SERVER_URL`
75    /// - `SPRING_APPLICATION_NAME`
76    ///
77    /// Optional:
78    /// - `SPRING_PROFILES_ACTIVE` defaults to `default`
79    /// - `SPRING_CONFIG_LABEL`
80    /// - `SPRING_CONFIG_USERNAME`
81    /// - `SPRING_CONFIG_PASSWORD`
82    /// - `SPRING_CONFIG_BEARER_TOKEN`
83    /// - `SPRING_CONFIG_INSECURE_TLS`
84    /// - `SPRING_CONFIG_TIMEOUT_SECS`
85    pub fn from_env() -> Result<Self> {
86        let server_url = required_env(Self::SERVER_URL_ENV)?;
87        let application = required_env(Self::APPLICATION_ENV)?;
88        let profiles = optional_env(Self::PROFILES_ENV)
89            .map(split_profiles)
90            .filter(|profiles| !profiles.is_empty())
91            .unwrap_or_else(|| vec!["default".to_string()]);
92        let label = optional_env(Self::LABEL_ENV);
93        let username = optional_env(Self::USERNAME_ENV);
94        let password = optional_env(Self::PASSWORD_ENV);
95        let bearer_token = optional_env(Self::BEARER_TOKEN_ENV);
96        let accept_invalid_tls = optional_env(Self::INSECURE_TLS_ENV)
97            .map(parse_env_bool)
98            .transpose()?
99            .unwrap_or(false);
100        let timeout = optional_env(Self::TIMEOUT_SECONDS_ENV)
101            .map(parse_timeout_seconds)
102            .transpose()?;
103
104        Self {
105            server_url,
106            application,
107            profiles,
108            label,
109            username,
110            password,
111            bearer_token,
112            accept_invalid_tls,
113            timeout,
114        }
115        .validate()
116    }
117
118    /// Sets the config label.
119    pub fn label(mut self, label: impl Into<String>) -> Self {
120        let label = label.into().trim().to_string();
121        self.label = if label.is_empty() { None } else { Some(label) };
122        self
123    }
124
125    /// Sets HTTP Basic authentication.
126    pub fn basic_auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
127        self.username = Some(username.into());
128        self.password = Some(password.into());
129        self.bearer_token = None;
130        self
131    }
132
133    /// Sets Bearer token authentication.
134    pub fn bearer_auth(mut self, token: impl Into<String>) -> Self {
135        self.bearer_token = Some(token.into());
136        self.username = None;
137        self.password = None;
138        self
139    }
140
141    /// Disables both TLS certificate and hostname validation for Config Server requests.
142    ///
143    /// This should only be enabled for development or controlled test environments.
144    pub fn danger_accept_invalid_tls(mut self, enabled: bool) -> Self {
145        self.accept_invalid_tls = enabled;
146        self
147    }
148
149    /// Sets the request timeout.
150    pub fn timeout(mut self, timeout: Duration) -> Self {
151        self.timeout = Some(timeout);
152        self
153    }
154
155    /// Returns the Config Server base URL.
156    pub fn server_url(&self) -> &str {
157        &self.server_url
158    }
159
160    /// Returns the Spring application name.
161    pub fn application(&self) -> &str {
162        &self.application
163    }
164
165    /// Returns the active profiles.
166    pub fn profiles(&self) -> &[String] {
167        &self.profiles
168    }
169
170    /// Returns the configured label, when set.
171    pub fn label_ref(&self) -> Option<&str> {
172        self.label.as_deref()
173    }
174
175    /// Builds a [`SpringConfigClient`] from the bootstrap settings.
176    pub fn build_client(&self) -> Result<SpringConfigClient> {
177        let mut builder = SpringConfigClient::builder(&self.server_url)?;
178
179        if self.accept_invalid_tls {
180            builder = builder.danger_accept_invalid_tls(true);
181        }
182
183        if let Some(label) = &self.label {
184            builder = builder.default_label(label);
185        }
186
187        if let Some(timeout) = self.timeout {
188            builder = builder.timeout(timeout);
189        }
190
191        if let Some(token) = &self.bearer_token {
192            builder = builder.bearer_auth(token);
193        } else if let Some(username) = &self.username {
194            builder = builder.basic_auth(username, self.password.clone().unwrap_or_default());
195        }
196
197        builder.build()
198    }
199
200    /// Builds an [`EnvironmentRequest`] from the bootstrap settings.
201    pub fn environment_request(&self) -> Result<EnvironmentRequest> {
202        let mut request = EnvironmentRequest::new(&self.application, self.profiles.clone())?;
203        if let Some(label) = &self.label {
204            request = request.label(label.clone());
205        }
206        Ok(request)
207    }
208
209    /// Loads the raw Spring `Environment`.
210    pub async fn load_environment(&self) -> Result<Environment> {
211        let client = self.build_client()?;
212        let request = self.environment_request()?;
213        client.fetch_environment(&request).await
214    }
215
216    /// Loads typed configuration directly from Spring Config Server.
217    pub async fn load_typed<T>(&self) -> Result<T>
218    where
219        T: DeserializeOwned,
220    {
221        let client = self.build_client()?;
222        let request = self.environment_request()?;
223        client.fetch_typed(&request).await
224    }
225
226    fn validate(mut self) -> Result<Self> {
227        self.server_url = self.server_url.trim().to_string();
228        self.application = self.application.trim().to_string();
229        self.profiles = self
230            .profiles
231            .into_iter()
232            .map(|value| value.trim().to_string())
233            .filter(|value| !value.is_empty())
234            .collect();
235        self.label = self
236            .label
237            .as_ref()
238            .map(|value| value.trim().to_string())
239            .filter(|value| !value.is_empty());
240
241        if self.server_url.is_empty() {
242            return Err(Error::MissingEnvironmentVariable {
243                name: Self::SERVER_URL_ENV,
244            });
245        }
246
247        if self.application.is_empty() {
248            return Err(Error::MissingEnvironmentVariable {
249                name: Self::APPLICATION_ENV,
250            });
251        }
252
253        if self.profiles.is_empty() {
254            return Err(Error::InvalidBootstrapConfiguration(
255                "at least one profile must be provided".to_string(),
256            ));
257        }
258
259        if self.bearer_token.is_some() && (self.username.is_some() || self.password.is_some()) {
260            return Err(Error::InvalidBootstrapConfiguration(
261                "basic authentication and bearer authentication are mutually exclusive".to_string(),
262            ));
263        }
264
265        Ok(self)
266    }
267}
268
269fn required_env(name: &'static str) -> Result<String> {
270    optional_env(name).ok_or(Error::MissingEnvironmentVariable { name })
271}
272
273fn optional_env(name: &'static str) -> Option<String> {
274    env::var(name)
275        .ok()
276        .map(|value| value.trim().to_string())
277        .filter(|value| !value.is_empty())
278}
279
280fn split_profiles(value: String) -> Vec<String> {
281    value
282        .split(',')
283        .map(|profile| profile.trim().to_string())
284        .filter(|profile| !profile.is_empty())
285        .collect()
286}
287
288fn parse_timeout_seconds(value: String) -> Result<Duration> {
289    let seconds = value
290        .parse::<u64>()
291        .map_err(|_| Error::InvalidEnvironmentVariable {
292            name: BootstrapConfig::TIMEOUT_SECONDS_ENV,
293            reason: "expected an unsigned integer",
294            value: value.clone(),
295        })?;
296
297    Ok(Duration::from_secs(seconds))
298}
299
300fn parse_env_bool(value: String) -> Result<bool> {
301    match value.trim().to_ascii_lowercase().as_str() {
302        "1" | "true" | "yes" => Ok(true),
303        "0" | "false" | "no" => Ok(false),
304        _ => Err(Error::InvalidEnvironmentVariable {
305            name: BootstrapConfig::INSECURE_TLS_ENV,
306            reason: "expected true, false, yes, no, 1, or 0",
307            value,
308        }),
309    }
310}