Skip to main content

tanzim_load/
http.rs

1//! HTTP loader (`http` feature).
2//!
3//! Fetches configuration bytes through a user-provided closure so this crate does not depend on
4//! any HTTP client library. You supply the actual transport (via [`Http::new`]); the loader
5//! validates options, invokes your closure, and normalizes the returned entries.
6//!
7//! **Source:** `http` (the resource is the URL and is required; an empty or unparseable resource
8//! is rejected with [`Error::InvalidResource`])
9//!
10//! # Behaviour
11//!
12//! - The `headers`, `timeout`, and `insecure` options are validated here, then passed to your
13//!   fetch closure — enforcing them (timeouts, TLS policy) is the closure's responsibility.
14//! - Whatever [`Payload`]s the closure returns are post-processed: `maybe_name`
15//!   and `maybe_format` are trimmed, emptied to `None`, and lower-cased when `lowercase = true`
16//!   (the default).
17//! - If the closure returns an error it is wrapped as
18//!   [`Error::Load`] with description `"fetch configuration"`.
19//!
20//! # Options
21//!
22//! - `headers` — map of string headers (default `{}`)
23//! - `timeout` — positive integer seconds (default `15`)
24//! - `insecure` — allow invalid TLS certificates (default `false`)
25//! - `lowercase` — boolean (default `true`; whether to lowercase entry names and formats)
26//!
27//! # Example
28//!
29//! ```text
30//! http(headers=(Authorization="TOKEN"),timeout=30,insecure=true):https://example.com/config.yml
31//! ```
32
33use 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
42/// The transport closure driving an [`Http`] loader — you implement the actual request here.
43///
44/// Called once per source with, in order:
45///
46/// - [`&Url`](Url) — the parsed resource URL.
47/// - `&HashMap<String, String>` — the validated `headers` option.
48/// - [`Duration`] — the `timeout` option; enforcing it is up to this closure.
49/// - `bool` — the `insecure` option (allow invalid TLS); honoring it is up to this closure.
50///
51/// Return one [`Payload`] per configuration entry fetched. On failure return an `Err(String)`;
52/// the loader wraps it as [`Error::Load`] with description
53/// `"fetch configuration"`. Names and formats on the returned payloads are normalized afterwards
54/// (trimmed, lower-cased per the `lowercase` option), so this closure may set them raw.
55///
56/// Must be `Send + Sync + 'static` so the loader can be shared across threads.
57pub type HttpFetchFn = Box<
58    dyn Fn(&Url, &HashMap<String, String>, Duration, bool) -> Result<Vec<Payload>, String>
59        + Send
60        + Sync
61        + 'static,
62>;
63
64/// Loader for the `http` source: fetches configuration bytes through a user-supplied closure.
65///
66/// This crate ships no HTTP client — you provide the transport as an [`HttpFetchFn`] when calling
67/// [`Http::new`]. The loader validates options, calls your closure with the resolved URL,
68/// headers, timeout, and TLS policy, then normalizes the returned entries (see the
69/// [module docs](self)).
70///
71/// # Example
72///
73/// ```
74/// use std::collections::HashMap;
75/// use std::time::Duration;
76/// use tanzim_load::{http::{Http, Url}, Load, Payload};
77/// use tanzim_source::SourceBuilder;
78///
79/// // A real fetch would call an HTTP client here; this canned closure needs no network.
80/// let http = Http::new(Box::new(
81///     |url: &Url, _headers: &HashMap<String, String>, _timeout: Duration, _insecure: bool| {
82///         Ok(vec![Payload {
83///             source: SourceBuilder::new()
84///                 .with_source("http")
85///                 .with_resource(url.as_str())
86///                 .build()
87///                 .unwrap(),
88///             maybe_name: Some("app".into()),
89///             maybe_format: Some("json".into()),
90///             content: br#"{"debug":true}"#.to_vec(),
91///         }])
92///     },
93/// ));
94///
95/// let source = SourceBuilder::new()
96///     .with_source("http")
97///     .with_resource("https://example.com/config.json")
98///     .build()
99///     .unwrap();
100///
101/// let payloads = http.load(source).unwrap();
102/// assert_eq!(payloads[0].maybe_name.as_deref(), Some("app"));
103/// ```
104pub struct Http {
105    fetch: HttpFetchFn,
106}
107
108impl Http {
109    /// Create an HTTP loader driven by `fetch`, the closure that performs the actual request.
110    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}