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#[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 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 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 pub fn bearer_auth(mut self, token: impl Into<String>) -> Self {
55 self.auth = Some(Auth::Bearer(token.into()));
56 self
57 }
58
59 pub fn danger_accept_invalid_certs(mut self, enabled: bool) -> Self {
64 self.accept_invalid_certs = enabled;
65 self
66 }
67
68 pub fn danger_accept_invalid_hostnames(mut self, enabled: bool) -> Self {
73 self.accept_invalid_hostnames = enabled;
74 self
75 }
76
77 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 pub fn timeout(mut self, timeout: Duration) -> Self {
89 self.timeout = Some(timeout);
90 self
91 }
92
93 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
95 self.connect_timeout = Some(timeout);
96 self
97 }
98
99 pub fn user_agent(mut self, value: impl Into<String>) -> Self {
101 self.user_agent = Some(value.into());
102 self
103 }
104
105 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 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#[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 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 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 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 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 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 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 pub async fn fetch_resource_document(
282 &self,
283 request: &ResourceRequest,
284 ) -> Result<ConfigDocument> {
285 self.fetch_resource(request).await?.parse()
286 }
287
288 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}