churust_core/config.rs
1//! Layered configuration: defaults < `churust.toml` < env (`CHURUST_*`) < code.
2
3use serde::Deserialize;
4
5/// The fully-resolved application configuration.
6///
7/// Deserializes from a `churust.toml` file (with a `[server]` table and an
8/// optional `[tls]` table) and can be overlaid with `CHURUST_*` environment
9/// variables via [`apply_env`](Config::apply_env). Apply it to a builder with
10/// [`AppBuilder::with_config`](crate::AppBuilder::with_config), or use
11/// [`Churust::from_config`](crate::Churust::from_config) to load and apply the
12/// defaults in one step. Unspecified fields fall back to sane defaults.
13///
14/// ```
15/// use churust_core::Config;
16///
17/// let cfg: Config = toml::from_str(r#"
18/// [server]
19/// host = "0.0.0.0"
20/// port = 9090
21/// "#).unwrap();
22/// assert_eq!(cfg.server.host, "0.0.0.0");
23/// assert_eq!(cfg.server.port, 9090);
24/// // unspecified fields keep their defaults
25/// assert_eq!(cfg.server.max_body_bytes, 1 << 20);
26/// assert!(cfg.tls.is_none());
27/// ```
28#[derive(Debug, Clone, Default, Deserialize)]
29#[serde(default)]
30pub struct Config {
31 /// Server-level settings (`[server]` table).
32 pub server: ServerSection,
33 /// Optional TLS settings (`[tls]` table); `None` serves plaintext HTTP.
34 pub tls: Option<TlsSection>,
35}
36
37/// The `[server]` configuration table.
38///
39/// Every field has a default (see [`ServerSection::default`]), so a partial
40/// `[server]` table is valid.
41#[derive(Debug, Clone, Deserialize)]
42#[serde(default)]
43pub struct ServerSection {
44 /// Bind address host (default `"127.0.0.1"`).
45 pub host: String,
46 /// Bind port (default `8080`).
47 pub port: u16,
48 /// Maximum accepted request body size in bytes (default `1 MiB`). Larger
49 /// bodies are rejected with `413 Payload Too Large`.
50 pub max_body_bytes: usize,
51 /// Per-request timeout in milliseconds (default `30000`). `0` disables the
52 /// timeout.
53 pub request_timeout_ms: u64,
54}
55
56/// The `[tls]` configuration table: paths to a PEM certificate chain and
57/// private key.
58///
59/// Only meaningful when the `tls` feature is enabled; otherwise the paths are
60/// ignored. Set it on a builder with [`AppBuilder::tls`](crate::AppBuilder::tls).
61#[derive(Debug, Clone, Deserialize)]
62pub struct TlsSection {
63 /// Path to the PEM-encoded certificate chain file.
64 pub cert: String,
65 /// Path to the PEM-encoded private key file.
66 pub key: String,
67}
68
69impl Default for ServerSection {
70 fn default() -> Self {
71 Self {
72 host: "127.0.0.1".into(),
73 port: 8080,
74 max_body_bytes: 1 << 20,
75 request_timeout_ms: 30_000,
76 }
77 }
78}
79
80impl Config {
81 /// Load configuration by layering, in increasing precedence: built-in
82 /// defaults, then the TOML file at `path` (if it exists and parses), then
83 /// `CHURUST_*` environment variables.
84 ///
85 /// A missing or unparseable file is treated as "use defaults" rather than an
86 /// error, so calling this without a config file present is safe.
87 ///
88 /// ```no_run
89 /// use churust_core::Config;
90 /// // Reads ./my-app.toml if present, then applies CHURUST_* env overrides.
91 /// let cfg = Config::load("my-app.toml");
92 /// let _ = cfg.server.port;
93 /// ```
94 pub fn load(path: &str) -> Self {
95 let mut cfg = match std::fs::read_to_string(path) {
96 Ok(text) => toml::from_str::<Config>(&text).unwrap_or_default(),
97 Err(_) => Config::default(),
98 };
99 cfg.apply_env(|k| std::env::var(k).ok());
100 cfg
101 }
102
103 /// Load configuration from the conventional `churust.toml` path (plus
104 /// `CHURUST_*` env overrides). A shorthand for `Config::load("churust.toml")`;
105 /// this is what [`Churust::from_config`](crate::Churust::from_config) uses.
106 ///
107 /// ```no_run
108 /// use churust_core::Config;
109 /// let cfg = Config::load_default();
110 /// let _ = cfg.server.host;
111 /// ```
112 pub fn load_default() -> Self {
113 Self::load("churust.toml")
114 }
115
116 /// Overlay `CHURUST_*` environment variables onto this config, using the
117 /// provided `get` lookup so the source can be injected in tests.
118 ///
119 /// Recognized keys: `CHURUST_SERVER_HOST`, `CHURUST_SERVER_PORT`,
120 /// `CHURUST_SERVER_MAX_BODY_BYTES`, `CHURUST_SERVER_REQUEST_TIMEOUT_MS`.
121 /// Values that fail to parse (e.g. a non-numeric port) are ignored, leaving
122 /// the existing value in place.
123 ///
124 /// ```
125 /// use churust_core::Config;
126 /// use std::collections::HashMap;
127 ///
128 /// let env: HashMap<&str, &str> = [("CHURUST_SERVER_PORT", "7000")].into_iter().collect();
129 /// let mut cfg = Config::default();
130 /// cfg.apply_env(|k| env.get(k).map(|s| s.to_string()));
131 /// assert_eq!(cfg.server.port, 7000);
132 /// ```
133 pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
134 if let Some(v) = get("CHURUST_SERVER_HOST") {
135 self.server.host = v;
136 }
137 if let Some(v) = get("CHURUST_SERVER_PORT").and_then(|s| s.parse().ok()) {
138 self.server.port = v;
139 }
140 if let Some(v) = get("CHURUST_SERVER_MAX_BODY_BYTES").and_then(|s| s.parse().ok()) {
141 self.server.max_body_bytes = v;
142 }
143 if let Some(v) = get("CHURUST_SERVER_REQUEST_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
144 self.server.request_timeout_ms = v;
145 }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::collections::HashMap;
153
154 #[test]
155 fn defaults_are_sane() {
156 let c = Config::default();
157 assert_eq!(c.server.port, 8080);
158 assert_eq!(c.server.max_body_bytes, 1 << 20);
159 assert!(c.tls.is_none());
160 }
161
162 #[test]
163 fn parses_toml() {
164 let text = r#"
165 [server]
166 host = "0.0.0.0"
167 port = 9090
168 "#;
169 let c: Config = toml::from_str(text).unwrap();
170 assert_eq!(c.server.host, "0.0.0.0");
171 assert_eq!(c.server.port, 9090);
172 // unspecified fields fall back to defaults
173 assert_eq!(c.server.max_body_bytes, 1 << 20);
174 }
175
176 #[test]
177 fn env_overrides_file() {
178 let mut c = Config::default();
179 let env: HashMap<&str, &str> = [("CHURUST_SERVER_PORT", "7000")].into_iter().collect();
180 c.apply_env(|k| env.get(k).map(|s| s.to_string()));
181 assert_eq!(c.server.port, 7000);
182 }
183}