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 resource is rejected
8//! 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 const NAME: &str = "HTTP";
38pub const SOURCE: &str = "http";
39
40/// The transport closure driving an [`Http`] loader — you implement the actual request here.
41///
42/// Called once per source with, in order:
43///
44/// - `&str` — the resolved URL (the source resource).
45/// - `&HashMap<String, String>` — the validated `headers` option.
46/// - [`Duration`] — the `timeout` option; enforcing it is up to this closure.
47/// - `bool` — the `insecure` option (allow invalid TLS); honoring it is up to this closure.
48///
49/// Return one [`Payload`] per configuration entry fetched. On failure return an `Err(String)`;
50/// the loader wraps it as [`Error::Load`] with description
51/// `"fetch configuration"`. Names and formats on the returned payloads are normalized afterwards
52/// (trimmed, lower-cased per the `lowercase` option), so this closure may set them raw.
53///
54/// Must be `Send + Sync + 'static` so the loader can be shared across threads.
55pub type HttpFetchFn = Box<
56    dyn Fn(&str, &HashMap<String, String>, Duration, bool) -> Result<Vec<Payload>, String>
57        + Send
58        + Sync
59        + 'static,
60>;
61
62/// Loader for the `http` source: fetches configuration bytes through a user-supplied closure.
63///
64/// This crate ships no HTTP client — you provide the transport as an [`HttpFetchFn`] when calling
65/// [`Http::new`]. The loader validates options, calls your closure with the resolved URL,
66/// headers, timeout, and TLS policy, then normalizes the returned entries (see the
67/// [module docs](self)).
68///
69/// # Example
70///
71/// ```
72/// use std::collections::HashMap;
73/// use std::time::Duration;
74/// use tanzim_load::{http::Http, Load, Payload};
75/// use tanzim_source::SourceBuilder;
76///
77/// // A real fetch would call an HTTP client here; this canned closure needs no network.
78/// let http = Http::new(Box::new(
79///     |url: &str, _headers: &HashMap<String, String>, _timeout: Duration, _insecure: bool| {
80///         Ok(vec![Payload {
81///             source: SourceBuilder::new()
82///                 .with_source("http")
83///                 .with_resource(url)
84///                 .build()
85///                 .unwrap(),
86///             maybe_name: Some("app".into()),
87///             maybe_format: Some("json".into()),
88///             content: br#"{"debug":true}"#.to_vec(),
89///         }])
90///     },
91/// ));
92///
93/// let source = SourceBuilder::new()
94///     .with_source("http")
95///     .with_resource("https://example.com/config.json")
96///     .build()
97///     .unwrap();
98///
99/// let payloads = http.load(source).unwrap();
100/// assert_eq!(payloads[0].maybe_name.as_deref(), Some("app"));
101/// ```
102pub struct Http {
103    fetch: HttpFetchFn,
104}
105
106impl Http {
107    /// Create an HTTP loader driven by `fetch`, the closure that performs the actual request.
108    pub fn new(fetch: HttpFetchFn) -> Self {
109        Self { fetch }
110    }
111}
112
113impl Load for Http {
114    fn name(&self) -> &str {
115        NAME
116    }
117
118    fn supported_source_list(&self) -> Vec<String> {
119        vec![SOURCE.to_string()]
120    }
121
122    fn load(&self, source: Source) -> Result<Vec<Payload>, Error> {
123        let options = source.options().clone();
124        let resource = source.resource().to_string();
125
126        if resource.is_empty() {
127            return Err(Error::InvalidResource {
128                loader: NAME.to_string(),
129                resource: resource.to_string(),
130                reason: "resource URL is required".into(),
131            });
132        }
133
134        for key in options.keys() {
135            if !matches!(key, "headers" | "timeout" | "insecure" | "lowercase") {
136                return Err(Error::InvalidOption {
137                    loader: NAME.to_string(),
138                    key: key.to_string(),
139                    reason: "unknown option".into(),
140                });
141            }
142        }
143        let headers = match options.get("headers") {
144            None => HashMap::new(),
145            Some(value) => {
146                let map = value.as_map().ok_or_else(|| Error::InvalidOption {
147                    loader: NAME.to_string(),
148                    key: "headers".to_string(),
149                    reason: format!("expected map, found {}", value.type_name()),
150                })?;
151                let mut headers = HashMap::with_capacity(map.len());
152                for (entry_key, entry_value) in map.iter() {
153                    headers.insert(
154                        entry_key.to_string(),
155                        entry_value
156                            .as_string()
157                            .cloned()
158                            .ok_or_else(|| Error::InvalidOption {
159                                loader: NAME.to_string(),
160                                key: "headers".to_string(),
161                                reason: format!(
162                                    "expected string, found {}",
163                                    entry_value.type_name()
164                                ),
165                            })?,
166                    );
167                }
168                headers
169            }
170        };
171        let timeout_seconds = match options.get("timeout") {
172            None => 15,
173            Some(value) => {
174                let integer = value.as_integer().ok_or_else(|| Error::InvalidOption {
175                    loader: NAME.to_string(),
176                    key: "timeout".to_string(),
177                    reason: format!("expected positive integer, found {}", value.type_name()),
178                })?;
179                if integer <= 0 {
180                    return Err(Error::InvalidOption {
181                        loader: NAME.to_string(),
182                        key: "timeout".to_string(),
183                        reason: "expected positive integer".into(),
184                    });
185                }
186                integer as u64
187            }
188        };
189        let insecure = match options.get("insecure") {
190            None => false,
191            Some(value) => value.as_bool().ok_or_else(|| Error::InvalidOption {
192                loader: NAME.to_string(),
193                key: "insecure".to_string(),
194                reason: format!("expected boolean, found {}", value.type_name()),
195            })?,
196        };
197        let lowercase = match options.get("lowercase") {
198            None => true,
199            Some(value) => value.as_bool().ok_or_else(|| Error::InvalidOption {
200                loader: NAME.to_string(),
201                key: "lowercase".to_string(),
202                reason: format!("expected boolean, found {}", value.type_name()),
203            })?,
204        };
205        let timeout = Duration::from_secs(timeout_seconds);
206
207        cfg_if! {
208            if #[cfg(feature = "tracing")] {
209                tracing::debug!(msg = "Fetching configuration via HTTP", resource = resource, timeout_seconds = timeout_seconds, header_count = headers.len(), insecure = insecure, lowercase = lowercase);
210            } else if #[cfg(feature = "logging")] {
211                log::debug!("msg=\"Fetching configuration via HTTP\" resource={resource} timeout_seconds={timeout_seconds} header_count={} insecure={insecure} lowercase={lowercase}", headers.len());
212            }
213        }
214
215        let fetched =
216            (self.fetch)(&resource, &headers, timeout, insecure).map_err(|error| Error::Load {
217                loader: NAME.to_string(),
218                resource: resource.to_string(),
219                description: "fetch configuration".into(),
220                source: error.into(),
221            })?;
222
223        let mut payloads = Vec::with_capacity(fetched.len());
224        for payload in fetched {
225            let name = match payload.maybe_name {
226                Some(name) => {
227                    let trimmed = name.trim();
228                    if trimmed.is_empty() {
229                        None
230                    } else if lowercase {
231                        let lower = trimmed.to_lowercase();
232                        if lower != trimmed {
233                            cfg_if! {
234                                if #[cfg(feature = "tracing")] {
235                                    tracing::debug!(msg = "Lowercased HTTP configuration entry name", from = trimmed, to = lower.as_str(), resource = resource);
236                                } else if #[cfg(feature = "logging")] {
237                                    log::debug!("msg=\"Lowercased HTTP configuration entry name\" from={trimmed} to={lower} resource={resource}");
238                                }
239                            }
240                        }
241                        Some(lower)
242                    } else {
243                        Some(trimmed.to_string())
244                    }
245                }
246                None => None,
247            };
248            let format = match payload.maybe_format {
249                Some(format) => {
250                    let trimmed = format.trim();
251                    if trimmed.is_empty() {
252                        None
253                    } else if lowercase {
254                        let lower = trimmed.to_lowercase();
255                        if lower != trimmed {
256                            cfg_if! {
257                                if #[cfg(feature = "tracing")] {
258                                    tracing::debug!(msg = "Lowercased HTTP configuration format", from = trimmed, to = lower.as_str(), resource = resource);
259                                } else if #[cfg(feature = "logging")] {
260                                    log::debug!("msg=\"Lowercased HTTP configuration format\" from={trimmed} to={lower} resource={resource}");
261                                }
262                            }
263                        }
264                        Some(lower)
265                    } else {
266                        Some(trimmed.to_string())
267                    }
268                }
269                None => None,
270            };
271            let payload = Payload {
272                source: source.clone(),
273                maybe_name: name,
274                maybe_format: format,
275                content: payload.content,
276            };
277            payloads.push(payload);
278        }
279
280        Ok(payloads)
281    }
282}
283
284#[cfg(all(test, feature = "http"))]
285mod tests {
286    use super::*;
287    use tanzim_source::SourceBuilder;
288
289    fn placeholder_source() -> Source {
290        SourceBuilder::new().with_source("http").build().unwrap()
291    }
292
293    #[test]
294    fn load_delegates_to_fetch_closure() {
295        let loader = Http::new(Box::new(|url, headers, timeout, insecure| {
296            assert_eq!(url, "https://example.com/config.json");
297            assert_eq!(
298                headers.get("Authorization").map(String::as_str),
299                Some("TOKEN")
300            );
301            assert_eq!(timeout, Duration::from_secs(30));
302            assert!(insecure);
303            Ok(vec![Payload {
304                source: placeholder_source(),
305                maybe_name: Some("demo".into()),
306                maybe_format: Some("json".into()),
307                content: br#"{"hello":"world"}"#.to_vec(),
308            }])
309        }));
310
311        let source = SourceBuilder::new()
312            .with_source("http")
313            .with_resource("https://example.com/config.json")
314            .with_option("headers", HashMap::from([("Authorization", "TOKEN")]))
315            .with_option("timeout", 30_i64)
316            .with_option("insecure", true)
317            .build()
318            .unwrap();
319        let loaded = loader.load(source).unwrap();
320        assert_eq!(loaded.len(), 1);
321        assert_eq!(loaded[0].maybe_name, Some("demo".to_string()));
322    }
323
324    #[test]
325    fn load_requires_resource() {
326        let loader = Http::new(Box::new(|_, _, _, _| {
327            Ok(vec![Payload {
328                source: placeholder_source(),
329                maybe_name: None,
330                maybe_format: None,
331                content: Vec::new(),
332            }])
333        }));
334        let source = SourceBuilder::new().with_source("http").build().unwrap();
335        let error = loader.load(source).unwrap_err();
336        assert!(matches!(error, Error::InvalidResource { .. }));
337    }
338
339    #[test]
340    fn name_and_supported_source_list() {
341        let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
342        assert_eq!(loader.name(), NAME);
343        assert_eq!(loader.supported_source_list(), vec![SOURCE.to_string()]);
344    }
345
346    #[test]
347    fn load_rejects_unknown_option() {
348        let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
349        let source = SourceBuilder::new()
350            .with_source("http")
351            .with_resource("https://example.com")
352            .with_option("bogus", true)
353            .build()
354            .unwrap();
355        let error = loader.load(source).unwrap_err();
356        assert!(matches!(error, Error::InvalidOption { .. }));
357    }
358
359    #[test]
360    fn load_rejects_bad_headers_type() {
361        let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
362        let source = SourceBuilder::new()
363            .with_source("http")
364            .with_resource("https://example.com")
365            .with_option("headers", "not-a-map")
366            .build()
367            .unwrap();
368        let error = loader.load(source).unwrap_err();
369        assert!(matches!(error, Error::InvalidOption { key, .. } if key == "headers"));
370    }
371
372    #[test]
373    fn load_rejects_non_string_header_value() {
374        let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
375        let source = SourceBuilder::new()
376            .with_source("http")
377            .with_resource("https://example.com")
378            .with_option("headers", HashMap::from([("Authorization", 1_i64)]))
379            .build()
380            .unwrap();
381        let error = loader.load(source).unwrap_err();
382        assert!(matches!(error, Error::InvalidOption { key, .. } if key == "headers"));
383    }
384
385    #[test]
386    fn load_uses_default_timeout() {
387        let loader = Http::new(Box::new(|_, _, timeout, _| {
388            assert_eq!(timeout, Duration::from_secs(15));
389            Ok(Vec::new())
390        }));
391        let source = SourceBuilder::new()
392            .with_source("http")
393            .with_resource("https://example.com")
394            .build()
395            .unwrap();
396        loader.load(source).unwrap();
397    }
398
399    #[test]
400    fn load_rejects_non_positive_timeout() {
401        let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
402        let source = SourceBuilder::new()
403            .with_source("http")
404            .with_resource("https://example.com")
405            .with_option("timeout", 0_i64)
406            .build()
407            .unwrap();
408        let error = loader.load(source).unwrap_err();
409        assert!(matches!(error, Error::InvalidOption { key, .. } if key == "timeout"));
410    }
411
412    #[test]
413    fn load_rejects_bad_insecure_type() {
414        let loader = Http::new(Box::new(|_, _, _, _| Ok(Vec::new())));
415        let source = SourceBuilder::new()
416            .with_source("http")
417            .with_resource("https://example.com")
418            .with_option("insecure", "yes")
419            .build()
420            .unwrap();
421        let error = loader.load(source).unwrap_err();
422        assert!(matches!(error, Error::InvalidOption { key, .. } if key == "insecure"));
423    }
424
425    #[test]
426    fn load_wraps_fetch_error() {
427        let loader = Http::new(Box::new(|_, _, _, _| Err("network down".into())));
428        let source = SourceBuilder::new()
429            .with_source("http")
430            .with_resource("https://example.com")
431            .build()
432            .unwrap();
433        let error = loader.load(source).unwrap_err();
434        assert!(
435            matches!(error, Error::Load { description, .. } if description == "fetch configuration")
436        );
437    }
438
439    #[test]
440    fn load_normalizes_trimmed_empty_name_and_format() {
441        let loader = Http::new(Box::new(|_, _, _, _| {
442            Ok(vec![Payload {
443                source: placeholder_source(),
444                maybe_name: Some("   ".into()),
445                maybe_format: Some("\t".into()),
446                content: Vec::new(),
447            }])
448        }));
449        let source = SourceBuilder::new()
450            .with_source("http")
451            .with_resource("https://example.com")
452            .build()
453            .unwrap();
454        let loaded = loader.load(source).unwrap();
455        assert_eq!(loaded[0].maybe_name, None);
456        assert_eq!(loaded[0].maybe_format, None);
457    }
458
459    #[test]
460    fn load_lowercases_name_and_format_by_default() {
461        let loader = Http::new(Box::new(|_, _, _, _| {
462            Ok(vec![Payload {
463                source: placeholder_source(),
464                maybe_name: Some(" Demo ".into()),
465                maybe_format: Some(" JSON ".into()),
466                content: Vec::new(),
467            }])
468        }));
469        let source = SourceBuilder::new()
470            .with_source("http")
471            .with_resource("https://example.com")
472            .build()
473            .unwrap();
474        let loaded = loader.load(source).unwrap();
475        assert_eq!(loaded[0].maybe_name.as_deref(), Some("demo"));
476        assert_eq!(loaded[0].maybe_format.as_deref(), Some("json"));
477    }
478
479    #[test]
480    fn load_preserves_case_when_lowercase_disabled() {
481        let loader = Http::new(Box::new(|_, _, _, _| {
482            Ok(vec![Payload {
483                source: placeholder_source(),
484                maybe_name: Some("Demo".into()),
485                maybe_format: Some("JSON".into()),
486                content: Vec::new(),
487            }])
488        }));
489        let source = SourceBuilder::new()
490            .with_source("http")
491            .with_resource("https://example.com")
492            .with_option("lowercase", false)
493            .build()
494            .unwrap();
495        let loaded = loader.load(source).unwrap();
496        assert_eq!(loaded[0].maybe_name.as_deref(), Some("Demo"));
497        assert_eq!(loaded[0].maybe_format.as_deref(), Some("JSON"));
498    }
499
500    #[test]
501    fn load_clones_source_onto_payloads() {
502        let loader = Http::new(Box::new(|_, _, _, _| {
503            Ok(vec![Payload {
504                source: placeholder_source(),
505                maybe_name: Some("app".into()),
506                maybe_format: Some("json".into()),
507                content: b"{}".to_vec(),
508            }])
509        }));
510        let source = SourceBuilder::new()
511            .with_source("http")
512            .with_resource("https://example.com/x")
513            .build()
514            .unwrap();
515        let loaded = loader.load(source.clone()).unwrap();
516        assert_eq!(loaded[0].source.resource(), source.resource());
517    }
518}