Skip to main content

rust_webx_core/
config.rs

1//! Built-in configuration support: appsettings.json + AppOptions pattern.
2//!
3//! The framework automatically loads `appsettings.json` (merged with
4//! `appsettings.Development.json` in dev mode) and binds it to the
5//! built-in `AppOptions` struct.  Users customize options via
6//! `HostBuilder::configure(|app| app.useOptions(|o| { ... }))`.
7
8use crate::mode::AppMode;
9use serde::Deserialize;
10use std::path::Path;
11
12// ---------------------------------------------------------------------------
13// IAppOptions trait (for user-defined option types)
14// ---------------------------------------------------------------------------
15
16/// Application options — binds to a section of appsettings.json.
17///
18/// Users define their own structs implementing this trait,
19/// then call `AppConfig::bind()` to bind values.
20pub trait IAppOptions: for<'de> Deserialize<'de> + Default + Send + Sync + 'static {}
21
22impl<T> IAppOptions for T where T: for<'de> Deserialize<'de> + Default + Send + Sync + 'static {}
23
24// ---------------------------------------------------------------------------
25// Built-in option types (matching standard appsettings.json layout)
26// ---------------------------------------------------------------------------
27
28/// Top-level application section.
29#[derive(Debug, Clone, Deserialize)]
30pub struct AppSection {
31    /// Application display name.
32    #[serde(default, rename = "Name")]
33    pub name: String,
34    /// Listen addresses as full URLs (e.g., "http://0.0.0.0:5000").
35    /// ASP.NET Core compatible. Default: ["http://0.0.0.0:5000"].
36    #[serde(default = "default_urls", rename = "Urls")]
37    pub urls: Vec<String>,
38    /// Maximum request body size in bytes. Default: 10 MB.
39    #[serde(default = "default_max_body_size", rename = "MaxBodySize")]
40    pub max_body_size: usize,
41    /// Maximum concurrent connections. Default: 10000.
42    #[serde(default = "default_max_connections", rename = "MaxConnections")]
43    pub max_connections: usize,
44}
45
46impl Default for AppSection {
47    fn default() -> Self {
48        Self {
49            name: String::new(),
50            urls: default_urls(),
51            max_body_size: default_max_body_size(),
52            max_connections: default_max_connections(),
53        }
54    }
55}
56
57fn default_urls() -> Vec<String> {
58    vec!["http://0.0.0.0:5000".to_string()]
59}
60
61fn default_max_body_size() -> usize {
62    10 * 1024 * 1024 // 10 MB
63}
64
65fn default_max_connections() -> usize {
66    10_000
67}
68
69/// JWT authentication section.
70#[derive(Debug, Clone, Deserialize, Default)]
71pub struct JwtSection {
72    /// HMAC secret for signing/verifying JWT tokens.
73    #[serde(default, rename = "Secret")]
74    pub secret: String,
75}
76
77/// CORS (Cross-Origin Resource Sharing) section.
78///
79/// JSON keys use PascalCase (`Origins`, `Methods`, …) to match appsettings
80/// and ASP.NET Core conventions — same as `App` / `Jwt` / `RateLimit`.
81#[derive(Debug, Clone, Deserialize)]
82#[serde(rename_all = "PascalCase")]
83pub struct CorsSection {
84    /// Allowed origins. Default: ["*"].
85    #[serde(default = "default_origins")]
86    pub origins: Vec<String>,
87    /// Allowed methods. Default: GET, POST, PUT, DELETE, PATCH, OPTIONS.
88    #[serde(default = "default_cors_methods")]
89    pub methods: Vec<String>,
90    /// Allowed headers. Default: Content-Type, Authorization.
91    #[serde(default = "default_cors_headers")]
92    pub headers: Vec<String>,
93    /// Response headers exposed to the client. Default: empty.
94    #[serde(default)]
95    pub expose_headers: Vec<String>,
96    /// Allow credentials. Default: false.
97    #[serde(default)]
98    pub allow_credentials: bool,
99    /// Preflight cache max-age in seconds. Default: 86400.
100    #[serde(default = "default_max_age")]
101    pub max_age: u32,
102}
103
104impl Default for CorsSection {
105    fn default() -> Self {
106        Self {
107            origins: default_origins(),
108            methods: default_cors_methods(),
109            headers: default_cors_headers(),
110            expose_headers: Vec::new(),
111            allow_credentials: false,
112            max_age: default_max_age(),
113        }
114    }
115}
116
117fn default_origins() -> Vec<String> {
118    vec!["*".to_string()]
119}
120
121fn default_cors_methods() -> Vec<String> {
122    vec![
123        "GET".to_string(),
124        "POST".to_string(),
125        "PUT".to_string(),
126        "DELETE".to_string(),
127        "PATCH".to_string(),
128        "OPTIONS".to_string(),
129    ]
130}
131
132fn default_cors_headers() -> Vec<String> {
133    vec!["Content-Type".to_string(), "Authorization".to_string()]
134}
135
136fn default_max_age() -> u32 {
137    86400
138}
139
140/// Per-IP rate limiting (token bucket).
141#[derive(Debug, Clone, Deserialize)]
142pub struct RateLimitSection {
143    /// Enable rate-limit middleware. Default: false (enable in Production appsettings).
144    #[serde(default, rename = "Enabled")]
145    pub enabled: bool,
146    /// Sustained requests per second per client IP.
147    #[serde(default = "default_rate_limit_rps", rename = "RequestsPerSecond")]
148    pub requests_per_second: f64,
149    /// Burst capacity before throttling.
150    #[serde(default = "default_rate_limit_burst", rename = "BurstSize")]
151    pub burst_size: u32,
152    /// Maximum distinct client IPs tracked (LRU eviction when exceeded).
153    #[serde(default = "default_rate_limit_max_ips", rename = "MaxTrackedIps")]
154    pub max_tracked_ips: usize,
155    /// Trust X-Forwarded-For / X-Real-IP headers for client IP extraction.
156    /// Only enable when behind a trusted reverse proxy. Default: false.
157    #[serde(default, rename = "TrustProxy")]
158    pub trust_proxy: bool,
159}
160
161impl Default for RateLimitSection {
162    fn default() -> Self {
163        Self {
164            enabled: false,
165            requests_per_second: default_rate_limit_rps(),
166            burst_size: default_rate_limit_burst(),
167            max_tracked_ips: default_rate_limit_max_ips(),
168            trust_proxy: false,
169        }
170    }
171}
172
173fn default_rate_limit_rps() -> f64 {
174    100.0
175}
176
177fn default_rate_limit_burst() -> u32 {
178    200
179}
180
181fn default_rate_limit_max_ips() -> usize {
182    10_000
183}
184
185/// HTTP request metrics (`GET /metrics` Prometheus text when enabled).
186#[derive(Debug, Clone, Deserialize, Default)]
187pub struct MetricsSection {
188    #[serde(default, rename = "Enabled")]
189    pub enabled: bool,
190}
191
192/// TLS (Transport Layer Security) section.
193///
194/// TLS is activated automatically when the `App.Urls` array
195/// contains one or more `https://` entries. The certificate
196/// and key paths are read from this section.
197#[derive(Debug, Clone, Deserialize, Default)]
198pub struct TlsSection {
199    /// Path to TLS certificate PEM file.
200    #[serde(default, rename = "CertPath")]
201    pub cert_path: String,
202    /// Path to TLS private key PEM file.
203    #[serde(default, rename = "KeyPath")]
204    pub key_path: String,
205}
206
207/// Standard application options loaded from appsettings.json.
208///
209/// Bound automatically by the framework.  Access via `host.options()`
210/// or customize via `app.useOptions(|o| { ... })`.
211#[derive(Debug, Clone, Deserialize, Default)]
212pub struct AppOptions {
213    /// Application settings.
214    #[serde(default, rename = "App")]
215    pub app: AppSection,
216    /// JWT authentication settings.
217    #[serde(default, rename = "Jwt")]
218    pub jwt: JwtSection,
219    /// CORS settings.
220    #[serde(default, rename = "Cors")]
221    pub cors: CorsSection,
222    /// TLS settings.
223    #[serde(default, rename = "Tls")]
224    pub tls: TlsSection,
225    /// Rate limiting settings.
226    #[serde(default, rename = "RateLimit")]
227    pub rate_limit: RateLimitSection,
228    /// Prometheus-style metrics endpoint settings.
229    #[serde(default, rename = "Metrics")]
230    pub metrics: MetricsSection,
231}
232
233// ---------------------------------------------------------------------------
234// Config loading helpers
235// ---------------------------------------------------------------------------
236
237/// Load the merged appsettings JSON (base + environment overlay + env overrides).
238///
239/// 按运行模式自动合并对应的 `appsettings.{Mode}.json` overlay:
240/// - `Development` → `appsettings.Development.json`
241/// - `Production`  → `appsettings.Production.json`
242///
243/// Environment variables prefixed with `APP__` override the corresponding JSON values.
244/// For example, `APP__App__Address=0.0.0.0:8080` overrides `{"App": {"Address": "..."}}`.
245pub fn load_appsettings(mode: AppMode) -> Option<serde_json::Value> {
246    let mut base = read_json_file("appsettings.json")?;
247
248    let overlay_name = mode.overlay_filename();
249    if let Some(overlay) = read_json_file(overlay_name) {
250        merge_json(&mut base, overlay);
251    }
252
253    // Well-known env vars (JWT_SECRET) — applied before APP__ so APP__Jwt__Secret wins.
254    apply_well_known_env_overrides(&mut base);
255
256    // Apply environment variable overrides (APP__Section__Key pattern)
257    apply_env_overrides(&mut base);
258
259    Some(base)
260}
261
262/// Apply conventional environment variables that map to config sections.
263///
264/// `JWT_SECRET` → `Jwt.Secret` (overridden by `APP__Jwt__Secret` if set).
265fn apply_well_known_env_overrides(config: &mut serde_json::Value) {
266    if let Ok(secret) = std::env::var("JWT_SECRET") {
267        if !secret.is_empty() {
268            set_json_value(config, &["Jwt", "Secret"], &secret);
269        }
270    }
271}
272
273/// Apply environment variable overrides following the `APP__Section__Key` pattern.
274fn apply_env_overrides(config: &mut serde_json::Value) {
275    for (key, value) in std::env::vars() {
276        if let Some(path) = key.strip_prefix("APP__") {
277            // Split by double underscore to get path segments
278            let segments: Vec<&str> = path.split("__").collect();
279            if segments.is_empty() {
280                continue;
281            }
282            set_json_value(config, &segments, &value);
283        }
284    }
285}
286
287/// Set a value in a JSON object following the given path segments.
288fn set_json_value(obj: &mut serde_json::Value, segments: &[&str], value: &str) {
289    if segments.is_empty() {
290        return;
291    }
292
293    let key = segments[0];
294
295    if let serde_json::Value::Object(map) = obj {
296        if segments.len() == 1 {
297            // Leaf: set the value, attempting to parse as JSON first
298            let parsed =
299                serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string()));
300            map.insert(key.to_string(), parsed);
301        } else if let Some(child) = map.get_mut(key) {
302            // Recurse into child
303            set_json_value(child, &segments[1..], value);
304        } else {
305            // Create intermediate objects as needed
306            let mut child = serde_json::json!({});
307            set_json_value(&mut child, &segments[1..], value);
308            map.insert(key.to_string(), child);
309        }
310    }
311}
312
313/// Bind a section of the config JSON to a deserializable type.
314pub fn bind_config<T: for<'de> Deserialize<'de> + Default>(
315    config: &serde_json::Value,
316    section: &str,
317) -> T {
318    if section.is_empty() || section == "." {
319        serde_json::from_value(config.clone()).unwrap_or_else(|e| {
320            tracing::warn!("[Config] Failed to bind root config to {}: {}, using defaults", std::any::type_name::<T>(), e);
321            T::default()
322        })
323    } else {
324        config
325            .get(section)
326            .map(|v| serde_json::from_value(v.clone()).unwrap_or_else(|e| {
327                tracing::warn!("[Config] Failed to bind section '{}' to {}: {}, using defaults", section, std::any::type_name::<T>(), e);
328                T::default()
329            }))
330            .unwrap_or_default()
331    }
332}
333
334/// Bind the entire config JSON to a type (for root-level deserialization).
335pub fn bind_root<T: for<'de> Deserialize<'de> + Default>(config: &serde_json::Value) -> T {
336    serde_json::from_value(config.clone()).unwrap_or_else(|e| {
337        tracing::warn!("[Config] Failed to bind root config to {}: {}, using defaults", std::any::type_name::<T>(), e);
338        T::default()
339    })
340}
341
342// ---------------------------------------------------------------------------
343// Internal helpers
344// ---------------------------------------------------------------------------
345
346fn read_json_file(path: impl AsRef<Path>) -> Option<serde_json::Value> {
347    let path = path.as_ref();
348
349    /// Try to read and parse a JSON file at the given path.
350    fn try_read(path: &Path) -> Option<serde_json::Value> {
351        let content = std::fs::read_to_string(path).ok()?;
352        serde_json::from_str(&content).ok()
353    }
354
355    // 1. 应用基准目录(exe 同级 / cwd / 上溯统一由 app_base 处理)。
356    //    部署与开发期均能定位到 appsettings.json 所在目录。
357    let base = crate::paths::app_base();
358    let candidate = base.join(path);
359    if let Some(value) = try_read(&candidate) {
360        return Some(value);
361    }
362
363    // 2. Try as-is (relative to current working directory) — 兜底
364    if let Some(value) = try_read(path) {
365        return Some(value);
366    }
367
368    None
369}
370
371fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
372    match (base, overlay) {
373        (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
374            for (k, v) in b {
375                merge_json(a.entry(k).or_insert(serde_json::Value::Null), v);
376            }
377        }
378        (a, b) => *a = b,
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn config_env_jwt_and_app_override_precedence() {
388        std::env::set_var("JWT_SECRET", "env-jwt-secret-that-is-long-enough-32");
389        let mut config = serde_json::json!({ "Jwt": { "Secret": "from-file" } });
390        apply_well_known_env_overrides(&mut config);
391        assert_eq!(
392            config["Jwt"]["Secret"],
393            "env-jwt-secret-that-is-long-enough-32"
394        );
395
396        std::env::set_var(
397            "APP__Jwt__Secret",
398            "from-app-override-secret-32chars-minimum",
399        );
400        apply_env_overrides(&mut config);
401        std::env::remove_var("JWT_SECRET");
402        std::env::remove_var("APP__Jwt__Secret");
403        assert_eq!(
404            config["Jwt"]["Secret"],
405            "from-app-override-secret-32chars-minimum"
406        );
407    }
408
409    #[test]
410    fn cors_section_binds_pascal_case_from_appsettings() {
411        // Regression: without rename_all=PascalCase, Origins fell back to ["*"]
412        // and Production fail-fast panicked even when Production overlay set explicit origins.
413        let json = serde_json::json!({
414            "Cors": {
415                "Origins": ["https://www.lusida.net", "https://lusida.net"],
416                "Methods": ["GET", "POST"],
417                "Headers": ["Content-Type", "Authorization"],
418                "AllowCredentials": true,
419                "MaxAge": 3600
420            }
421        });
422        let opts: AppOptions = bind_root(&json);
423        assert_eq!(
424            opts.cors.origins,
425            vec![
426                "https://www.lusida.net".to_string(),
427                "https://lusida.net".to_string()
428            ]
429        );
430        assert_eq!(opts.cors.methods, vec!["GET".to_string(), "POST".to_string()]);
431        assert!(opts.cors.allow_credentials);
432        assert_eq!(opts.cors.max_age, 3600);
433        assert!(!opts.cors.origins.iter().any(|o| o == "*"));
434    }
435
436    #[test]
437    fn production_overlay_merge_keeps_explicit_cors_origins() {
438        let mut base = serde_json::json!({
439            "App": { "Urls": ["http://localhost:5000"] },
440            "Cors": { "Origins": ["*"] },
441            "Jwt": { "Secret": "docbit-dev-secret-change-in-production" }
442        });
443        let overlay = serde_json::json!({
444            "App": { "Urls": ["http://0.0.0.0:8100"] },
445            "Cors": {
446                "Origins": [
447                    "https://www.lusida.net",
448                    "https://lusida.net"
449                ]
450            },
451            "Jwt": { "Secret": "" }
452        });
453        merge_json(&mut base, overlay);
454        let opts: AppOptions = bind_root(&base);
455        assert_eq!(opts.app.urls, vec!["http://0.0.0.0:8100".to_string()]);
456        assert_eq!(
457            opts.cors.origins,
458            vec![
459                "https://www.lusida.net".to_string(),
460                "https://lusida.net".to_string()
461            ]
462        );
463        assert!(opts.jwt.secret.is_empty());
464    }
465}