Skip to main content

scconfig_rs/
client.rs

1use std::time::Duration;
2
3use reqwest::{
4    Client, Url,
5    header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue},
6};
7
8use crate::{
9    ConfigDocument, ConfigResource, DocumentFormat, Environment, EnvironmentFormat,
10    EnvironmentRequest, Error, ResourceRequest, Result,
11};
12
13#[derive(Debug, Clone)]
14enum Auth {
15    Basic {
16        username: String,
17        password: Option<String>,
18    },
19    Bearer(String),
20}
21
22/// Builder for [`SpringConfigClient`].
23#[derive(Debug, Clone)]
24pub struct SpringConfigClientBuilder {
25    base_url: Url,
26    default_label: Option<String>,
27    auth: Option<Auth>,
28    accept_invalid_certs: bool,
29    accept_invalid_hostnames: bool,
30    timeout: Option<Duration>,
31    connect_timeout: Option<Duration>,
32    user_agent: Option<String>,
33    headers: HeaderMap,
34}
35
36impl SpringConfigClientBuilder {
37    /// Sets a default label used when a request does not provide one explicitly.
38    pub fn default_label(mut self, label: impl Into<String>) -> Self {
39        let label = label.into().trim().to_string();
40        self.default_label = if label.is_empty() { None } else { Some(label) };
41        self
42    }
43
44    /// Configures HTTP Basic authentication.
45    pub fn basic_auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
46        self.auth = Some(Auth::Basic {
47            username: username.into(),
48            password: Some(password.into()),
49        });
50        self
51    }
52
53    /// Configures Bearer token authentication.
54    pub fn bearer_auth(mut self, token: impl Into<String>) -> Self {
55        self.auth = Some(Auth::Bearer(token.into()));
56        self
57    }
58
59    /// Disables TLS certificate validation.
60    ///
61    /// This should only be enabled for development or controlled test environments
62    /// that use self-signed or otherwise untrusted certificates.
63    pub fn danger_accept_invalid_certs(mut self, enabled: bool) -> Self {
64        self.accept_invalid_certs = enabled;
65        self
66    }
67
68    /// Disables TLS hostname validation.
69    ///
70    /// This should only be enabled for development or controlled test environments
71    /// where the certificate hostname does not match the requested host.
72    pub fn danger_accept_invalid_hostnames(mut self, enabled: bool) -> Self {
73        self.accept_invalid_hostnames = enabled;
74        self
75    }
76
77    /// Disables both TLS certificate and hostname validation.
78    ///
79    /// This is a convenience method for local development or smoke tests against
80    /// environments with broken or private TLS setups. Do not enable this in production.
81    pub fn danger_accept_invalid_tls(mut self, enabled: bool) -> Self {
82        self.accept_invalid_certs = enabled;
83        self.accept_invalid_hostnames = enabled;
84        self
85    }
86
87    /// Sets the total request timeout.
88    pub fn timeout(mut self, timeout: Duration) -> Self {
89        self.timeout = Some(timeout);
90        self
91    }
92
93    /// Sets the connect timeout.
94    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
95        self.connect_timeout = Some(timeout);
96        self
97    }
98
99    /// Sets the User-Agent header.
100    pub fn user_agent(mut self, value: impl Into<String>) -> Self {
101        self.user_agent = Some(value.into());
102        self
103    }
104
105    /// Adds a default HTTP header that will be sent with every request.
106    pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self> {
107        let name_string = name.as_ref().to_string();
108        let value_string = value.as_ref().to_string();
109
110        let name = HeaderName::from_bytes(name_string.as_bytes())
111            .map_err(|_| Error::InvalidHeaderName(name_string.clone()))?;
112        let value =
113            HeaderValue::from_str(&value_string).map_err(|_| Error::InvalidHeaderValue {
114                name: name_string,
115                value: value_string,
116            })?;
117
118        self.headers.insert(name, value);
119        Ok(self)
120    }
121
122    /// Builds the client.
123    pub fn build(self) -> Result<SpringConfigClient> {
124        let mut builder = Client::builder().default_headers(self.headers);
125
126        if self.accept_invalid_certs {
127            builder = builder.danger_accept_invalid_certs(true);
128        }
129
130        if self.accept_invalid_hostnames {
131            builder = builder.danger_accept_invalid_hostnames(true);
132        }
133
134        if let Some(timeout) = self.timeout {
135            builder = builder.timeout(timeout);
136        }
137
138        if let Some(connect_timeout) = self.connect_timeout {
139            builder = builder.connect_timeout(connect_timeout);
140        }
141
142        builder =
143            builder.user_agent(self.user_agent.unwrap_or_else(|| {
144                format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
145            }));
146
147        let http_client = builder.build().map_err(|source| Error::Transport {
148            url: self.base_url.to_string(),
149            source,
150        })?;
151
152        Ok(SpringConfigClient {
153            base_url: self.base_url,
154            default_label: self.default_label,
155            auth: self.auth,
156            http_client,
157        })
158    }
159}
160
161/// Async Spring Cloud Config client for Rust applications.
162#[derive(Debug, Clone)]
163pub struct SpringConfigClient {
164    base_url: Url,
165    default_label: Option<String>,
166    auth: Option<Auth>,
167    http_client: Client,
168}
169
170impl SpringConfigClient {
171    /// Creates a new builder from the Config Server base URL.
172    ///
173    /// The base URL may already contain a Config Server prefix such as `/config`.
174    pub fn builder(base_url: impl AsRef<str>) -> Result<SpringConfigClientBuilder> {
175        let base_url_string = base_url.as_ref().trim().to_string();
176        let base_url = Url::parse(&base_url_string)
177            .map_err(|_| Error::InvalidBaseUrl(base_url_string.clone()))?;
178
179        if base_url.query().is_some() || base_url.fragment().is_some() {
180            return Err(Error::InvalidBaseUrlShape(base_url_string));
181        }
182
183        Ok(SpringConfigClientBuilder {
184            base_url,
185            default_label: None,
186            auth: None,
187            accept_invalid_certs: false,
188            accept_invalid_hostnames: false,
189            timeout: None,
190            connect_timeout: None,
191            user_agent: None,
192            headers: HeaderMap::new(),
193        })
194    }
195
196    /// Fetches the Spring `Environment` JSON payload.
197    pub async fn fetch_environment(&self, request: &EnvironmentRequest) -> Result<Environment> {
198        let url = self.environment_url(request, None)?;
199        let response = self.send(url.clone()).await?;
200        let body = self.read_text(response, &url).await?;
201
202        serde_json::from_str(&body).map_err(|source| Error::Json {
203            url: url.to_string(),
204            source,
205        })
206    }
207
208    /// Fetches the effective configuration and deserializes it into a Rust type.
209    pub async fn fetch_typed<T>(&self, request: &EnvironmentRequest) -> Result<T>
210    where
211        T: serde::de::DeserializeOwned,
212    {
213        self.fetch_environment(request).await?.deserialize()
214    }
215
216    /// Fetches an alternative-format environment representation as UTF-8 text.
217    pub async fn fetch_environment_as_text(
218        &self,
219        request: &EnvironmentRequest,
220        format: EnvironmentFormat,
221    ) -> Result<String> {
222        let url = self.environment_url(request, Some(format))?;
223        let response = self.send(url.clone()).await?;
224        self.read_text(response, &url).await
225    }
226
227    /// Fetches an alternative-format environment representation and parses it into a document.
228    pub async fn fetch_environment_document(
229        &self,
230        request: &EnvironmentRequest,
231        format: EnvironmentFormat,
232    ) -> Result<ConfigDocument> {
233        let origin = self.environment_url(request, Some(format))?.to_string();
234        let text = self.fetch_environment_as_text(request, format).await?;
235        let document_format = match format {
236            EnvironmentFormat::Yml | EnvironmentFormat::Yaml => DocumentFormat::Yaml,
237            EnvironmentFormat::Properties => DocumentFormat::Properties,
238        };
239
240        ConfigDocument::from_text(&origin, document_format, text)
241    }
242
243    /// Fetches a resource from the plain-text Spring endpoint.
244    ///
245    /// The request always includes `Accept: application/octet-stream` so the same API works
246    /// for both text and binary files.
247    pub async fn fetch_resource(&self, request: &ResourceRequest) -> Result<ConfigResource> {
248        let url = self.resource_url(request)?;
249        let response = self
250            .send_with_header(
251                url.clone(),
252                ACCEPT,
253                HeaderValue::from_static("application/octet-stream"),
254            )
255            .await?;
256
257        let content_type = response
258            .headers()
259            .get(CONTENT_TYPE)
260            .and_then(|value| value.to_str().ok())
261            .map(ToOwned::to_owned);
262
263        let bytes = response
264            .bytes()
265            .await
266            .map_err(|source| Error::Transport {
267                url: url.to_string(),
268                source,
269            })?
270            .to_vec();
271
272        Ok(ConfigResource::new(
273            request.path().to_string(),
274            url.to_string(),
275            content_type,
276            bytes,
277        ))
278    }
279
280    /// Fetches and parses a resource into a [`ConfigDocument`].
281    pub async fn fetch_resource_document(
282        &self,
283        request: &ResourceRequest,
284    ) -> Result<ConfigDocument> {
285        self.fetch_resource(request).await?.parse()
286    }
287
288    /// Fetches a resource and deserializes it into a Rust type.
289    pub async fn fetch_resource_typed<T>(&self, request: &ResourceRequest) -> Result<T>
290    where
291        T: serde::de::DeserializeOwned,
292    {
293        self.fetch_resource(request).await?.deserialize()
294    }
295
296    async fn send(&self, url: Url) -> Result<reqwest::Response> {
297        let request = self.apply_auth(self.http_client.get(url.clone()));
298        let response = request.send().await.map_err(|source| Error::Transport {
299            url: url.to_string(),
300            source,
301        })?;
302
303        Self::ensure_success(url, response).await
304    }
305
306    async fn send_with_header(
307        &self,
308        url: Url,
309        header_name: HeaderName,
310        header_value: HeaderValue,
311    ) -> Result<reqwest::Response> {
312        let request = self
313            .apply_auth(self.http_client.get(url.clone()))
314            .header(header_name, header_value);
315        let response = request.send().await.map_err(|source| Error::Transport {
316            url: url.to_string(),
317            source,
318        })?;
319
320        Self::ensure_success(url, response).await
321    }
322
323    async fn ensure_success(url: Url, response: reqwest::Response) -> Result<reqwest::Response> {
324        let status = response.status();
325        if status.is_success() {
326            Ok(response)
327        } else {
328            let body = response.text().await.unwrap_or_default();
329            Err(Error::HttpStatus {
330                status,
331                url: url.to_string(),
332                body,
333            })
334        }
335    }
336
337    async fn read_text(&self, response: reqwest::Response, url: &Url) -> Result<String> {
338        let bytes = response
339            .bytes()
340            .await
341            .map_err(|source| Error::Transport {
342                url: url.to_string(),
343                source,
344            })?
345            .to_vec();
346
347        String::from_utf8(bytes).map_err(|source| Error::Utf8 {
348            url: url.to_string(),
349            source,
350        })
351    }
352
353    fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
354        match &self.auth {
355            Some(Auth::Basic { username, password }) => {
356                request.basic_auth(username, password.clone())
357            }
358            Some(Auth::Bearer(token)) => request.bearer_auth(token),
359            None => request,
360        }
361    }
362
363    fn environment_url(
364        &self,
365        request: &EnvironmentRequest,
366        format: Option<EnvironmentFormat>,
367    ) -> Result<Url> {
368        let mut url = self.base_url.clone();
369        let error_url = url.to_string();
370        let application = encode_segment(request.application());
371        let profiles = encode_segment(&request.joined_profiles());
372        let effective_label = request
373            .label_ref()
374            .or(self.default_label.as_deref())
375            .map(encode_segment);
376
377        {
378            let mut segments = url
379                .path_segments_mut()
380                .map_err(|_| Error::InvalidBaseUrl(error_url.clone()))?;
381
382            segments.push(&application);
383
384            match (format, effective_label.as_deref()) {
385                (None, Some(label)) => {
386                    segments.push(&profiles);
387                    segments.push(label);
388                }
389                (None, None) => {
390                    segments.push(&profiles);
391                }
392                (Some(format), Some(label)) => {
393                    segments.push(&profiles);
394                    segments.push(&format!("{label}{}", format.suffix()));
395                }
396                (Some(format), None) => {
397                    segments.push(&format!("{profiles}{}", format.suffix()));
398                }
399            }
400        }
401
402        if format.is_some() && request.resolve_placeholders_enabled() {
403            url.query_pairs_mut()
404                .append_pair("resolvePlaceholders", "true");
405        }
406
407        Ok(url)
408    }
409
410    fn resource_url(&self, request: &ResourceRequest) -> Result<Url> {
411        let mut url = self.base_url.clone();
412        let error_url = url.to_string();
413        let application = encode_segment(request.application());
414        let profiles = encode_segment(&request.joined_profiles());
415        let effective_label = request
416            .label_ref()
417            .or(self.default_label.as_deref())
418            .map(encode_segment);
419        let resource_segments = request.path_segments();
420
421        {
422            let mut segments = url
423                .path_segments_mut()
424                .map_err(|_| Error::InvalidBaseUrl(error_url.clone()))?;
425
426            segments.push(&application);
427            segments.push(&profiles);
428
429            if let Some(label) = effective_label.as_deref() {
430                segments.push(label);
431            }
432
433            for segment in &resource_segments {
434                segments.push(segment);
435            }
436        }
437
438        if effective_label.is_none() {
439            url.query_pairs_mut().append_pair("useDefaultLabel", "true");
440        }
441
442        Ok(url)
443    }
444}
445
446fn encode_segment(value: &str) -> String {
447    value.trim().replace('/', "(_)")
448}