Skip to main content

httpward_core/config/
global.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Arc;
4use schemars::JsonSchema;
5use crate::config::site::SiteConfig;
6use crate::config::strategy::{Strategy, StrategyRef, LegacyStrategyCollection as StrategyCollection};
7
8/// Global application configuration (loaded from httpward.yaml)
9/// Inherits all fields from SiteConfig plus global-specific settings
10#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
11#[serde(default, deny_unknown_fields)]
12pub struct GlobalConfig {
13    /// Primary domain name (used for SNI matching & logging)
14    #[serde(default)]
15    pub domain: String,
16
17    /// Additional domain names / aliases
18    #[serde(default)]
19    pub domains: Vec<String>,
20
21    /// Network listeners (bind address + port + optional TLS)
22    #[serde(default)]
23    pub listeners: Vec<Listener>,
24
25    /// Global routing rules (executed before site-level routes)
26    #[serde(default)]
27    pub routes: Vec<Route>,
28
29    /// Logging configuration
30    #[serde(default)]
31    pub log: LogConfig,
32
33    /// Proxy identifier used in Via header
34    #[serde(default = "default_proxy_id")]
35    pub proxy_id: String,
36
37    /// Path to directory with per-site .yaml / .yml files
38    #[serde(default)]
39    pub sites_enabled: PathBuf,
40
41    /// Default strategy for all domains and routes
42    #[serde(default = "default_strategy")]
43    pub strategy: Option<StrategyRef>,
44
45    /// Global strategy definitions
46    #[serde(default)]
47    pub strategies: StrategyCollection,
48}
49
50impl Default for GlobalConfig {
51    fn default() -> Self {
52        Self {
53            domain: String::default(),
54            domains: Vec::default(),
55            listeners: Vec::default(),
56            routes: Vec::default(),
57            log: LogConfig::default(),
58            proxy_id: default_proxy_id(),
59            sites_enabled: PathBuf::default(),
60            strategy: default_strategy(),
61            strategies: StrategyCollection::default(),
62        }
63    }
64}
65
66impl GlobalConfig {
67    /// Get all domains for this global config (primary + additional)
68    pub fn get_all_domains(&self) -> Vec<String> {
69        let mut domains = Vec::with_capacity(1 + self.domains.len());
70        if !self.domain.is_empty() {
71            domains.push(self.domain.clone());
72        }
73        domains.extend(self.domains.iter().cloned());
74        domains
75    }
76
77    /// Check if this global config has any domains configured
78    pub fn has_domains(&self) -> bool {
79        !self.domain.is_empty() || !self.domains.is_empty()
80    }
81
82    /// Convert global config to site config
83    pub fn to_site_config(&self) -> SiteConfig {
84        SiteConfig {
85            domain: self.domain.clone(),
86            domains: self.domains.clone(),
87            listeners: self.listeners.clone(),
88            routes: self.routes.clone(),
89            strategy: self.strategy.clone(),
90            strategies: self.strategies.clone(),
91        }
92    }
93
94    /// Get the default strategy
95    pub fn get_default_strategy(&self) -> Option<Strategy> {
96        match &self.strategy {
97            Some(strategy_ref) => strategy_ref.resolve(&self.strategies),
98            None => None,
99        }
100    }
101}
102
103#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
104pub struct Listener {
105    /// Bind address (default: 0.0.0.0)
106    #[serde(default = "default_host")]
107    pub host: String,
108
109    /// TCP port
110    #[serde(default)]
111    pub port: u16,
112
113    /// Optional TLS configuration
114    #[serde(default)]
115    pub tls: Option<Tls>,
116}
117
118fn default_host() -> String {
119    "0.0.0.0".to_string()
120}
121
122#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
123pub struct Tls {
124    #[serde(default)]
125    pub self_signed: bool,
126    #[serde(default)]
127    pub cert: PathBuf,
128    #[serde(default)]
129    pub key: PathBuf,
130}
131
132/// Single routing rule — proxy / static / redirect
133#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
134#[serde(untagged)]
135pub enum Route {
136    Proxy {
137        #[serde(rename = "match")]
138        r#match: Match,
139        backend: String,
140        #[serde(default)]
141        strategy: Option<StrategyRef>,
142        #[serde(default)]
143        strategies: Option<StrategyCollection>,
144    },
145    Static {
146        #[serde(rename = "match")]
147        r#match: Match,
148        static_dir: PathBuf,
149        #[serde(default)]
150        strategy: Option<StrategyRef>,
151        #[serde(default)]
152        strategies: Option<StrategyCollection>,
153    },
154    Redirect {
155        #[serde(rename = "match")]
156        r#match: Match,
157        redirect: Redirect,
158        #[serde(default)]
159        strategy: Option<StrategyRef>,
160        #[serde(default)]
161        strategies: Option<StrategyCollection>,
162    },
163}
164
165impl Route {
166    /// Get the strategy reference for this route
167    pub fn get_strategy(&self) -> Option<&StrategyRef> {
168        match self {
169            Route::Proxy { strategy, .. } => strategy.as_ref(),
170            Route::Static { strategy, .. } => strategy.as_ref(),
171            Route::Redirect { strategy, .. } => strategy.as_ref(),
172        }
173    }
174
175    /// Get the strategy collection for this route
176    pub fn get_strategies(&self) -> Option<&StrategyCollection> {
177        match self {
178            Route::Proxy { strategies, .. } => strategies.as_ref(),
179            Route::Static { strategies, .. } => strategies.as_ref(),
180            Route::Redirect { strategies, .. } => strategies.as_ref(),
181        }
182    }
183
184    /// Get the match configuration for this route
185    pub fn get_match(&self) -> &Match {
186        match self {
187            Route::Proxy { r#match, .. } => r#match,
188            Route::Static { r#match, .. } => r#match,
189            Route::Redirect { r#match, .. } => r#match,
190        }
191    }
192}
193
194#[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema)]
195pub struct Match {
196    /// Using matchit library https://github.com/ibraheemdev/matchit
197    #[serde(default)]
198    pub path: Option<String>,
199
200    /// Using basic regexp. Please use path if it's possible.
201    #[serde(default)]
202    pub path_regex: Option<String>,
203}
204
205#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
206pub struct Redirect {
207    pub to: String,
208
209    #[serde(default = "default_redirect_code")]
210    pub code: u16,
211}
212
213fn default_redirect_code() -> u16 {
214    301
215}
216
217#[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema)]
218pub struct LogConfig {
219    /// Logging level ("trace" | "debug" | "info" | "warn" | "error")
220    #[serde(default = "default_log_level")]
221    pub level: String,
222}
223
224fn default_log_level() -> String {
225    "warn".to_string()
226}
227
228fn default_strategy() -> Option<StrategyRef> {
229    Some(StrategyRef::Named("default".to_string()))
230}
231
232fn default_proxy_id() -> String {
233    "httpward".to_string()
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::config::strategy::StrategyRef;
240    use crate::config::MiddlewareConfig;
241
242    #[test]
243    fn test_global_default_strategy() {
244        let config = GlobalConfig::default();
245        
246        // Should have Some(StrategyRef::Named("default")) by default
247        assert!(config.strategy.is_some(), "Strategy should be Some, got {:?}", config.strategy);
248        
249        match config.strategy.unwrap() {
250            StrategyRef::Named(name) => assert_eq!(name, "default"),
251            _ => panic!("Expected Named strategy"),
252        }
253    }
254
255    #[test]
256    fn test_global_strategy_resolution() {
257        let mut config = GlobalConfig::default();
258        
259        // Add a default strategy to the collection
260        config.strategies.insert("default".to_string(), vec![
261            crate::config::strategy::MiddlewareConfig::new_named_json(
262                "logging".to_string(),
263                serde_json::json!({"level": "info"})
264            )
265        ]);
266        
267        // Should resolve the default strategy
268        let resolved = config.get_default_strategy();
269        assert!(resolved.is_some());
270        
271        let strategy = resolved.unwrap();
272        assert_eq!(strategy.name, "default");
273        assert_eq!(strategy.middleware.len(), 1);
274        assert_eq!(strategy.middleware[0].name(), "logging");
275    }
276
277    #[test]
278    fn test_global_inline_strategy() {
279        let mut config = GlobalConfig::default();
280        
281        // Set an inline strategy using Strategy
282        let inline_strategy = Strategy {
283            name: "inline_test".to_string(),
284            middleware: Arc::new(vec![
285                crate::config::strategy::MiddlewareConfig::new_named_json(
286                    "rate_limit".to_string(),
287                    serde_json::json!({"requests": 1000, "window": "1m"})
288                ),
289                crate::config::strategy::MiddlewareConfig::new_named_json(
290                    "logging".to_string(),
291                    serde_json::json!({"level": "debug"})
292                )
293            ])
294        };
295        
296        config.strategy = Some(StrategyRef::InlineMiddleware(vec![
297            MiddlewareConfig::new_named_json(
298                "rate_limit".to_string(),
299                serde_json::json!({"requests": 1000, "window": "1m"})
300            ),
301            MiddlewareConfig::new_named_json(
302                "logging".to_string(),
303                serde_json::json!({"level": "debug"})
304            )
305        ]));
306        
307        // Should resolve the inline strategy
308        let resolved = config.get_default_strategy();
309        assert!(resolved.is_some());
310        
311        let strategy = resolved.unwrap();
312        assert_eq!(strategy.name, "inline");
313        assert_eq!(strategy.middleware.len(), 2);
314        assert_eq!(strategy.middleware[0].name(), "rate_limit");
315        assert_eq!(strategy.middleware[1].name(), "logging");
316    }
317}