1use 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
40pub type HttpFetchFn = Box<
56 dyn Fn(&str, &HashMap<String, String>, Duration, bool) -> Result<Vec<Payload>, String>
57 + Send
58 + Sync
59 + 'static,
60>;
61
62pub struct Http {
103 fetch: HttpFetchFn,
104}
105
106impl Http {
107 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}