Skip to main content

httpward_core/config/
loader.rs

1// src/config/loader.rs
2use super::{GlobalConfig, SiteConfig};
3use super::strategy::{LegacyStrategyCollection as StrategyCollection, MiddlewareConfig};
4use anyhow::{Context, Result};
5use glob::glob;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use schemars::JsonSchema;
10use tracing::info;
11
12/// Combined configuration in memory: global + all loaded sites
13#[derive(Debug, Clone, JsonSchema)]
14pub struct AppConfig {
15    pub global: GlobalConfig,
16    pub sites: Vec<SiteConfig>,
17}
18
19pub fn load(config_path: impl AsRef<Path>) -> Result<AppConfig> {
20    let config_path = config_path.as_ref();
21
22    // 1. Load global config
23    let global_content =
24        fs::read_to_string(&config_path).context("Cannot read httpward.yaml config file")?;
25
26
27    let mut global: GlobalConfig = serde_yaml::from_str(&global_content)
28        .context("Cannot parse httpward.yaml config (YAML error)")?;
29
30    // 2. Load default strategies from strategies.yml and merge with existing strategies
31    if let Some(strategies_from_file) = load_default_strategies(config_path.parent().unwrap_or(Path::new(".")))? {
32        // Merge strategies from file with existing ones (global strategies take precedence)
33        for (name, middleware) in strategies_from_file {
34            if !global.strategies.contains_key(&name) {
35                global.strategies.insert(name, middleware);
36            }
37        }
38
39    }
40
41    // Validate each listener
42    for (index, listener) in global.listeners.iter().enumerate() {
43        if listener.port == 0 && listener.tls.is_some() {
44            panic!(
45                "Config `{}`: listener #{} has invalid port 0. Please set up the port.",
46                "httpward.yaml", index
47            );
48        }
49    }
50
51    // 3. Load per-site configs
52    let sites_dir = &global.sites_enabled;
53    let mut sites = Vec::new();
54
55    if sites_dir.exists() {
56        for pattern in ["*.yaml", "*.yml"] {
57            let full_pattern = sites_dir.join(pattern);
58            for entry in
59                glob(full_pattern.to_str().unwrap_or_default()).context("glob pattern error")?
60            {
61                let path = entry.context("glob entry error")?;
62
63                let content = fs::read_to_string(&path)
64                    .context(format!("Cannot read site config: {:?}", path))?;
65
66                let site: SiteConfig = serde_yaml::from_str(&content)
67                    .context(format!("Cannot parse site config {:?}: YAML error", path))?;
68
69                let file_name = path
70                    .file_name()
71                    .and_then(|n| n.to_str())
72                    .unwrap_or("<unknown>");
73
74                validate_site_config(&site, file_name).context(format!(
75                    "Invalid site config {:?}: no domain specified",
76                    path
77                ))?;
78
79                info!("Loaded site config: {}", file_name);
80                sites.push(site);
81            }
82        }
83    }
84
85    Ok(AppConfig { global, sites })
86}
87
88fn validate_site_config(site: &SiteConfig, file_name: &str) -> Result<()> {
89    if site.domain.is_empty() && site.domains.is_empty() {
90        panic!(
91            "Error in the config: `{}`, must have at least `domain` or one entry in `domains`",
92            file_name
93        );
94    }
95
96    // Validate each listener
97    for (index, listener) in site.listeners.iter().enumerate() {
98        if listener.port == 0 && listener.tls.is_some() {
99            panic!(
100                "Config `{}`: listener #{} has invalid port 0. Please set up the port.",
101                file_name, index
102            );
103        }
104    }
105
106    Ok(())
107}
108
109/// Load default strategies from strategies.yml in the given directory
110/// Returns None if strategies.yml doesn't exist or is empty
111fn load_default_strategies(base_dir: &Path) -> Result<Option<StrategyCollection>> {
112    let strategies_path = base_dir.join("strategies.yml");
113    
114    if !strategies_path.exists() {
115        // Try strategies.yaml as fallback
116        let strategies_yaml_path = base_dir.join("strategies.yaml");
117        if !strategies_yaml_path.exists() {
118            return Ok(None);
119        }
120        return load_strategies_from_file(&strategies_yaml_path);
121    }
122    
123    load_strategies_from_file(&strategies_path)
124}
125
126/// Load strategies from a specific file
127fn load_strategies_from_file(strategies_path: &PathBuf) -> Result<Option<StrategyCollection>> {
128    
129    let content = fs::read_to_string(strategies_path)
130        .with_context(|| format!("Cannot read strategies file: {:?}", strategies_path))?;
131
132    
133    // Parse the YAML content - strategies.yml is a direct map of strategy names to middleware arrays
134    let strategies_map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(&content)
135        .with_context(|| format!("Cannot parse strategies file {:?}: YAML error", strategies_path))?;
136
137    // Return None if no strategies are defined
138    if strategies_map.is_empty() {
139        return Ok(None);
140    }
141    
142    // Convert to StrategyCollection
143    let mut strategies = StrategyCollection::new();
144    for (name, value) in strategies_map {
145        // Parse each strategy value as a vector of middleware configurations
146        let middleware: Vec<MiddlewareConfig> = serde_yaml::from_value(value)
147            .with_context(|| format!("Cannot parse strategy '{}'", name))?;
148
149        strategies.insert(name, middleware);
150    }
151
152    Ok(Some(strategies))
153}