1use crate::{Error, Load, Payload, Source};
34use cfg_if::cfg_if;
35use std::{collections::HashMap, time::Duration};
36
37pub use url::Url;
38
39pub const NAME: &str = "HTTP";
40pub const SOURCE: &str = "http";
41
42pub type HttpFetchFn = Box<
58 dyn Fn(&Url, &HashMap<String, String>, Duration, bool) -> Result<Vec<Payload>, String>
59 + Send
60 + Sync
61 + 'static,
62>;
63
64pub struct Http {
105 fetch: HttpFetchFn,
106}
107
108impl Http {
109 pub fn new(fetch: HttpFetchFn) -> Self {
111 Self { fetch }
112 }
113}
114
115impl Load for Http {
116 fn name(&self) -> &str {
117 NAME
118 }
119
120 fn supported_source_list(&self) -> Vec<String> {
121 vec![SOURCE.to_string()]
122 }
123
124 fn load(&self, source: Source) -> Result<Vec<Payload>, Error> {
125 let options = source.options().clone();
126 let resource = source.resource().to_string();
127
128 if resource.is_empty() {
129 return Err(Error::InvalidResource {
130 loader: NAME.to_string(),
131 resource: resource.to_string(),
132 reason: "resource URL is required".into(),
133 });
134 }
135 let url = url::Url::parse(&resource).map_err(|error| Error::InvalidResource {
136 loader: NAME.to_string(),
137 resource: resource.clone(),
138 reason: error.to_string(),
139 })?;
140
141 let headers = match options.get("headers") {
142 None => HashMap::new(),
143 Some(value) => {
144 let map = value.as_map().ok_or_else(|| Error::InvalidOption {
145 loader: NAME.to_string(),
146 key: "headers".to_string(),
147 reason: format!("expected map, found {}", value.type_name()),
148 })?;
149 let mut headers = HashMap::with_capacity(map.len());
150 for (entry_key, entry_value) in map.iter() {
151 headers.insert(
152 entry_key.to_string(),
153 entry_value
154 .as_string()
155 .cloned()
156 .ok_or_else(|| Error::InvalidOption {
157 loader: NAME.to_string(),
158 key: "headers".to_string(),
159 reason: format!(
160 "expected string, found {}",
161 entry_value.type_name()
162 ),
163 })?,
164 );
165 }
166 headers
167 }
168 };
169 let timeout_seconds = match options.get("timeout") {
170 None => 15,
171 Some(value) => {
172 let integer = value.as_integer().ok_or_else(|| Error::InvalidOption {
173 loader: NAME.to_string(),
174 key: "timeout".to_string(),
175 reason: format!("expected positive integer, found {}", value.type_name()),
176 })?;
177 if integer <= 0 {
178 return Err(Error::InvalidOption {
179 loader: NAME.to_string(),
180 key: "timeout".to_string(),
181 reason: "expected positive integer".into(),
182 });
183 }
184 integer as u64
185 }
186 };
187 let insecure = match options.get("insecure") {
188 None => false,
189 Some(value) => value.as_bool().ok_or_else(|| Error::InvalidOption {
190 loader: NAME.to_string(),
191 key: "insecure".to_string(),
192 reason: format!("expected boolean, found {}", value.type_name()),
193 })?,
194 };
195 let lowercase = match options.get("lowercase") {
196 None => true,
197 Some(value) => value.as_bool().ok_or_else(|| Error::InvalidOption {
198 loader: NAME.to_string(),
199 key: "lowercase".to_string(),
200 reason: format!("expected boolean, found {}", value.type_name()),
201 })?,
202 };
203 let timeout = Duration::from_secs(timeout_seconds);
204
205 cfg_if! {
206 if #[cfg(feature = "tracing")] {
207 tracing::debug!(msg = "Fetching configuration via HTTP", resource = resource, timeout_seconds = timeout_seconds, header_count = headers.len(), insecure = insecure, lowercase = lowercase);
208 } else if #[cfg(feature = "logging")] {
209 log::debug!("msg=\"Fetching configuration via HTTP\" resource={resource} timeout_seconds={timeout_seconds} header_count={} insecure={insecure} lowercase={lowercase}", headers.len());
210 }
211 }
212
213 let fetched =
214 (self.fetch)(&url, &headers, timeout, insecure).map_err(|error| Error::Load {
215 loader: NAME.to_string(),
216 resource: resource.to_string(),
217 description: "fetch configuration".into(),
218 source: error.into(),
219 })?;
220
221 let mut payloads = Vec::with_capacity(fetched.len());
222 for payload in fetched {
223 let name = match payload.maybe_name {
224 Some(name) => {
225 let trimmed = name.trim();
226 if trimmed.is_empty() {
227 None
228 } else if lowercase {
229 let lower = trimmed.to_lowercase();
230 if lower != trimmed {
231 cfg_if! {
232 if #[cfg(feature = "tracing")] {
233 tracing::debug!(msg = "Lowercased HTTP configuration entry name", from = trimmed, to = lower.as_str(), resource = resource);
234 } else if #[cfg(feature = "logging")] {
235 log::debug!("msg=\"Lowercased HTTP configuration entry name\" from={trimmed} to={lower} resource={resource}");
236 }
237 }
238 }
239 Some(lower)
240 } else {
241 Some(trimmed.to_string())
242 }
243 }
244 None => None,
245 };
246 let format = match payload.maybe_format {
247 Some(format) => {
248 let trimmed = format.trim();
249 if trimmed.is_empty() {
250 None
251 } else if lowercase {
252 let lower = trimmed.to_lowercase();
253 if lower != trimmed {
254 cfg_if! {
255 if #[cfg(feature = "tracing")] {
256 tracing::debug!(msg = "Lowercased HTTP configuration format", from = trimmed, to = lower.as_str(), resource = resource);
257 } else if #[cfg(feature = "logging")] {
258 log::debug!("msg=\"Lowercased HTTP configuration format\" from={trimmed} to={lower} resource={resource}");
259 }
260 }
261 }
262 Some(lower)
263 } else {
264 Some(trimmed.to_string())
265 }
266 }
267 None => None,
268 };
269 let payload = Payload {
270 source: source.clone(),
271 maybe_name: name,
272 maybe_format: format,
273 content: payload.content,
274 };
275 payloads.push(payload);
276 }
277
278 Ok(payloads)
279 }
280}
281
282#[cfg(all(test, feature = "http"))]
283mod tests {
284 use super::*;
285 use tanzim_source::SourceBuilder;
286
287 fn placeholder_source() -> Source {
288 SourceBuilder::new().with_source("http").build().unwrap()
289 }
290
291 #[test]
292 fn load_delegates_to_fetch_closure() {
293 let loader = Http::new(Box::new(|url, headers, timeout, insecure| {
294 assert_eq!(url.as_str(), "https://example.com/config.json");
295 assert_eq!(
296 headers.get("Authorization").map(String::as_str),
297 Some("TOKEN")
298 );
299 assert_eq!(timeout, Duration::from_secs(30));
300 assert!(insecure);
301 Ok(vec![Payload {
302 source: placeholder_source(),
303 maybe_name: Some("demo".into()),
304 maybe_format: Some("json".into()),
305 content: br#"{"hello":"world"}"#.to_vec(),
306 }])
307 }));
308
309 let source = SourceBuilder::new()
310 .with_source("http")
311 .with_resource("https://example.com/config.json")
312 .with_option("headers", HashMap::from([("Authorization", "TOKEN")]))
313 .with_option("timeout", 30_i64)
314 .with_option("insecure", true)
315 .build()
316 .unwrap();
317 let loaded = loader.load(source).unwrap();
318 assert_eq!(loaded.len(), 1);
319 assert_eq!(loaded[0].maybe_name, Some("demo".to_string()));
320 }
321
322 #[test]
323 fn load_rejects_invalid_url() {
324 let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
325 let source = SourceBuilder::new()
326 .with_source("http")
327 .with_resource("not a url")
328 .build()
329 .unwrap();
330 let error = loader.load(source).unwrap_err();
331 assert!(matches!(error, Error::InvalidResource { .. }));
332 }
333
334 #[test]
335 fn load_requires_resource() {
336 let loader = Http::new(Box::new(|_, _, _, _| {
337 Ok(vec![Payload {
338 source: placeholder_source(),
339 maybe_name: None,
340 maybe_format: None,
341 content: Vec::new(),
342 }])
343 }));
344 let source = SourceBuilder::new().with_source("http").build().unwrap();
345 let error = loader.load(source).unwrap_err();
346 assert!(matches!(error, Error::InvalidResource { .. }));
347 }
348
349 #[test]
350 fn name_and_supported_source_list() {
351 let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
352 assert_eq!(loader.name(), NAME);
353 assert_eq!(loader.supported_source_list(), vec![SOURCE.to_string()]);
354 }
355
356 #[test]
357 fn load_ignores_unknown_option() {
358 let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
359 let source = SourceBuilder::new()
360 .with_source("http")
361 .with_resource("https://example.com")
362 .with_option("bogus", true)
363 .build()
364 .unwrap();
365 let loaded = loader.load(source).unwrap();
366 assert!(loaded.is_empty());
367 }
368
369 #[test]
370 fn load_rejects_bad_headers_type() {
371 let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
372 let source = SourceBuilder::new()
373 .with_source("http")
374 .with_resource("https://example.com")
375 .with_option("headers", "not-a-map")
376 .build()
377 .unwrap();
378 let error = loader.load(source).unwrap_err();
379 assert!(matches!(error, Error::InvalidOption { key, .. } if key == "headers"));
380 }
381
382 #[test]
383 fn load_rejects_non_string_header_value() {
384 let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
385 let source = SourceBuilder::new()
386 .with_source("http")
387 .with_resource("https://example.com")
388 .with_option("headers", HashMap::from([("Authorization", 1_i64)]))
389 .build()
390 .unwrap();
391 let error = loader.load(source).unwrap_err();
392 assert!(matches!(error, Error::InvalidOption { key, .. } if key == "headers"));
393 }
394
395 #[test]
396 fn load_uses_default_timeout() {
397 let loader = Http::new(Box::new(|_, _, timeout, _| {
398 assert_eq!(timeout, Duration::from_secs(15));
399 Ok(Vec::new())
400 }));
401 let source = SourceBuilder::new()
402 .with_source("http")
403 .with_resource("https://example.com")
404 .build()
405 .unwrap();
406 loader.load(source).unwrap();
407 }
408
409 #[test]
410 fn load_rejects_non_positive_timeout() {
411 let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
412 let source = SourceBuilder::new()
413 .with_source("http")
414 .with_resource("https://example.com")
415 .with_option("timeout", 0_i64)
416 .build()
417 .unwrap();
418 let error = loader.load(source).unwrap_err();
419 assert!(matches!(error, Error::InvalidOption { key, .. } if key == "timeout"));
420 }
421
422 #[test]
423 fn load_rejects_bad_insecure_type() {
424 let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
425 let source = SourceBuilder::new()
426 .with_source("http")
427 .with_resource("https://example.com")
428 .with_option("insecure", "yes")
429 .build()
430 .unwrap();
431 let error = loader.load(source).unwrap_err();
432 assert!(matches!(error, Error::InvalidOption { key, .. } if key == "insecure"));
433 }
434
435 #[test]
436 fn load_wraps_fetch_error() {
437 let loader = Http::new(Box::new(|_, _, _, _| Err("network down".into())));
438 let source = SourceBuilder::new()
439 .with_source("http")
440 .with_resource("https://example.com")
441 .build()
442 .unwrap();
443 let error = loader.load(source).unwrap_err();
444 assert!(
445 matches!(error, Error::Load { description, .. } if description == "fetch configuration")
446 );
447 }
448
449 #[test]
450 fn load_normalizes_trimmed_empty_name_and_format() {
451 let loader = Http::new(Box::new(|_, _, _, _| {
452 Ok(vec![Payload {
453 source: placeholder_source(),
454 maybe_name: Some(" ".into()),
455 maybe_format: Some("\t".into()),
456 content: Vec::new(),
457 }])
458 }));
459 let source = SourceBuilder::new()
460 .with_source("http")
461 .with_resource("https://example.com")
462 .build()
463 .unwrap();
464 let loaded = loader.load(source).unwrap();
465 assert_eq!(loaded[0].maybe_name, None);
466 assert_eq!(loaded[0].maybe_format, None);
467 }
468
469 #[test]
470 fn load_lowercases_name_and_format_by_default() {
471 let loader = Http::new(Box::new(|_, _, _, _| {
472 Ok(vec![Payload {
473 source: placeholder_source(),
474 maybe_name: Some(" Demo ".into()),
475 maybe_format: Some(" JSON ".into()),
476 content: Vec::new(),
477 }])
478 }));
479 let source = SourceBuilder::new()
480 .with_source("http")
481 .with_resource("https://example.com")
482 .build()
483 .unwrap();
484 let loaded = loader.load(source).unwrap();
485 assert_eq!(loaded[0].maybe_name.as_deref(), Some("demo"));
486 assert_eq!(loaded[0].maybe_format.as_deref(), Some("json"));
487 }
488
489 #[test]
490 fn load_preserves_case_when_lowercase_disabled() {
491 let loader = Http::new(Box::new(|_, _, _, _| {
492 Ok(vec![Payload {
493 source: placeholder_source(),
494 maybe_name: Some("Demo".into()),
495 maybe_format: Some("JSON".into()),
496 content: Vec::new(),
497 }])
498 }));
499 let source = SourceBuilder::new()
500 .with_source("http")
501 .with_resource("https://example.com")
502 .with_option("lowercase", false)
503 .build()
504 .unwrap();
505 let loaded = loader.load(source).unwrap();
506 assert_eq!(loaded[0].maybe_name.as_deref(), Some("Demo"));
507 assert_eq!(loaded[0].maybe_format.as_deref(), Some("JSON"));
508 }
509
510 #[test]
511 fn load_clones_source_onto_payloads() {
512 let loader = Http::new(Box::new(|_, _, _, _| {
513 Ok(vec![Payload {
514 source: placeholder_source(),
515 maybe_name: Some("app".into()),
516 maybe_format: Some("json".into()),
517 content: b"{}".to_vec(),
518 }])
519 }));
520 let source = SourceBuilder::new()
521 .with_source("http")
522 .with_resource("https://example.com/x")
523 .build()
524 .unwrap();
525 let loaded = loader.load(source.clone()).unwrap();
526 assert_eq!(loaded[0].source.resource(), source.resource());
527 }
528}