Skip to main content

doido_controller/
config.rs

1//! Per-environment application configuration loaded from `config/<env>.yml`.
2//!
3//! [`Config`] is a trait so applications can supply their own backing store;
4//! [`YamlConfig`] is the default implementation that deserializes the YAML file
5//! for the environment reported by [`Environment::get_env`].
6
7use doido_core::Environment;
8use serde::Deserialize;
9
10/// Re-exported so `config::LoggerConfig` resolves; the logger config lives in
11/// `doido-core` alongside the logger it drives.
12pub use doido_core::logger::LoggerConfig;
13
14/// Server bind settings. The listen address is the `bind` IP joined with `port`
15/// (e.g. `0.0.0.0:3000`).
16#[derive(Debug, Clone, Deserialize)]
17pub struct ServerConfig {
18    pub bind: String,
19    pub port: u16,
20}
21
22impl Default for ServerConfig {
23    fn default() -> Self {
24        Self {
25            bind: "0.0.0.0".to_string(),
26            port: 3000,
27        }
28    }
29}
30
31/// Opt-in CORS settings (spec 07 `[middleware.cors]`). Disabled unless
32/// `enabled: true`. Each dimension must be configured explicitly: empty lists
33/// deny that dimension. Use `"*"` in a list to allow any value for that
34/// dimension. With `allow_credentials: true`, origin/method/header wildcards
35/// use mirror-request semantics instead of `*` (required by the CORS spec and
36/// `tower-http`).
37#[derive(Debug, Clone, Default, Deserialize)]
38pub struct CorsConfig {
39    #[serde(default)]
40    pub enabled: bool,
41    #[serde(default)]
42    pub allowed_origins: Vec<String>,
43    #[serde(default)]
44    pub allowed_methods: Vec<String>,
45    #[serde(default)]
46    pub allowed_headers: Vec<String>,
47    #[serde(default)]
48    pub allow_credentials: bool,
49}
50
51/// Opt-in middleware settings (spec 07 `[middleware]`).
52#[derive(Debug, Clone, Default, Deserialize)]
53pub struct MiddlewareConfig {
54    #[serde(default)]
55    pub cors: CorsConfig,
56}
57
58/// Application configuration. Used as a trait object (`Box<dyn Config>`) so the
59/// backing store can be swapped without touching call sites.
60pub trait Config: Send + Sync {
61    /// Server bind/port settings.
62    fn server(&self) -> &ServerConfig;
63    /// Logging settings.
64    fn logger(&self) -> &LoggerConfig;
65    /// Opt-in middleware settings (CORS, …).
66    fn middleware(&self) -> &MiddlewareConfig;
67}
68
69/// File-based [`Config`] deserialized from `config/<env>.yml`.
70#[derive(Debug, Clone, Default, Deserialize)]
71pub struct YamlConfig {
72    #[serde(default)]
73    pub server: ServerConfig,
74    #[serde(default)]
75    pub logger: LoggerConfig,
76    #[serde(default)]
77    pub middleware: MiddlewareConfig,
78}
79
80impl Config for YamlConfig {
81    fn server(&self) -> &ServerConfig {
82        &self.server
83    }
84
85    fn logger(&self) -> &LoggerConfig {
86        &self.logger
87    }
88
89    fn middleware(&self) -> &MiddlewareConfig {
90        &self.middleware
91    }
92}
93
94impl YamlConfig {
95    /// Loads `config/<env>.yml` for the environment from [`Environment::get_env`].
96    pub fn load() -> std::io::Result<Self> {
97        Self::load_env(Environment::get_env())
98    }
99
100    /// Loads `config/<env>.yml` for a specific environment.
101    pub fn load_env(env: Environment) -> std::io::Result<Self> {
102        let path = format!("config/{}.yml", env.as_str());
103        let contents = std::fs::read_to_string(&path)?;
104        Self::from_yaml(&contents)
105    }
106
107    /// Parses a [`YamlConfig`] from a YAML string.
108    pub fn from_yaml(yaml: &str) -> std::io::Result<Self> {
109        serde_norway::from_str(yaml)
110            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
111    }
112}
113
114/// Loads the current environment's configuration as a trait object, falling
115/// back to [`Default`] values when the file is missing or invalid.
116pub fn load() -> Box<dyn Config> {
117    Box::new(YamlConfig::load().unwrap_or_default())
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn parses_logger_level() {
126        let config = YamlConfig::from_yaml("logger:\n  level: debug\n").unwrap();
127        assert_eq!(config.logger().level, "debug");
128        assert_eq!(
129            config.logger().directives(),
130            doido_core::logger::directives_for_level("debug")
131        );
132    }
133
134    #[test]
135    fn explicit_directives_override_level() {
136        let yaml = "logger:\n  level: info\n  directives: warn,my_app=debug\n";
137        let config = YamlConfig::from_yaml(yaml).unwrap();
138        assert_eq!(config.logger().directives(), "warn,my_app=debug");
139    }
140
141    #[test]
142    fn defaults_to_info_when_logger_section_absent() {
143        let config = YamlConfig::from_yaml("server:\n  bind: 0.0.0.0\n  port: 3000\n").unwrap();
144        assert_eq!(config.logger().level, "info");
145        assert!(config.logger().sql);
146        assert!(config.logger().file.is_none());
147        assert_eq!(
148            config.logger().directives(),
149            doido_core::logger::DEFAULT_DIRECTIVES
150        );
151    }
152}