Skip to main content

apimock_config/
config.rs

1//! The `Config` struct: orchestrates loading, validation, and relative-path
2//! resolution for `apimock.toml` and every file it references.
3//!
4//! # What moved in 5.0
5//!
6//! Pre-5.0 `Config::new` also compiled every Rhai middleware. That step
7//! produced `MiddlewareHandler` values — which live in `apimock-server`
8//! after the split — so we deliberately stop there now. Middleware
9//! compilation happens in the server crate, *driven by* the paths
10//! listed in this struct's `service.middlewares_file_paths`. The
11//! separation keeps config dependency-free of Rhai and hyper, and
12//! prevents the GUI-oriented snapshot API (coming in stage 2) from
13//! reaching into execution state it has no business seeing.
14
15use apimock_routing::RuleSet;
16use constant::*;
17use listener_config::ListenerConfig;
18use log_config::LogConfig;
19use serde::Deserialize;
20use service_config::ServiceConfig;
21
22use std::{fs, path::Path};
23
24use crate::{
25    error::{ConfigError, ConfigResult},
26    path_util::current_dir_to_file_parent_dir_relative_path,
27};
28
29pub mod constant;
30pub mod listener_config;
31pub mod log_config;
32pub mod service_config;
33
34/// Top-level application configuration, corresponding one-to-one with
35/// `apimock.toml`.
36#[derive(Clone, Deserialize)]
37pub struct Config {
38    /// Where this config was loaded from. Kept so we can resolve relative
39    /// paths (rule sets, middlewares, respond dirs) against the config
40    /// file's parent directory — not the process's working directory.
41    #[serde(skip)]
42    pub file_path: Option<String>,
43
44    pub listener: Option<ListenerConfig>,
45    pub log: Option<LogConfig>,
46    pub service: ServiceConfig,
47}
48
49impl Config {
50    /// Build a `Config` by reading the TOML file, resolving rule-set
51    /// paths, and validating the result.
52    ///
53    /// Middleware paths are *recorded* on the returned Config but not
54    /// compiled here — the server crate performs compilation. See the
55    /// module docstring for why.
56    pub fn new(
57        config_file_path: Option<&String>,
58        fallback_respond_dir_path: Option<&String>,
59    ) -> ConfigResult<Self> {
60        let mut ret = Self::init(config_file_path)?;
61
62        ret.set_rule_sets()?;
63
64        ret.compute_fallback_respond_dir(fallback_respond_dir_path)?;
65
66        if !ret.validate() {
67            return Err(ConfigError::Validation);
68        }
69
70        log::info!("{}", ret);
71
72        Ok(ret)
73    }
74
75    /// Load + parse the TOML file. Returns `Config::default()` when no
76    /// path is provided (this is the zero-config "just serve a folder"
77    /// path).
78    fn init(config_file_path: Option<&String>) -> ConfigResult<Self> {
79        let Some(config_file_path) = config_file_path else {
80            return Ok(Config::default());
81        };
82
83        log::info!("[config] {}\n", config_file_path);
84
85        let path = Path::new(config_file_path);
86        let toml_string =
87            fs::read_to_string(config_file_path).map_err(|e| ConfigError::ConfigRead {
88                path: path.to_path_buf(),
89                source: e,
90            })?;
91
92        let mut config: Config =
93            toml::from_str(&toml_string).map_err(|e| ConfigError::ConfigParse {
94                path: path.to_path_buf(),
95                canonical: path.canonicalize().ok(),
96                source: e,
97            })?;
98        config.file_path = Some(config_file_path.to_owned());
99
100        Ok(config)
101    }
102
103    /// Load every rule-set file listed in `service.rule_sets`.
104    fn set_rule_sets(&mut self) -> ConfigResult<()> {
105        let relative_dir_path = self.current_dir_to_parent_dir_relative_path()?;
106
107        let Some(rule_sets_file_paths) = self.service.rule_sets_file_paths.as_ref() else {
108            return Ok(());
109        };
110
111        let mut rule_sets = Vec::with_capacity(rule_sets_file_paths.len());
112        for (rule_set_idx, rule_set_file_path) in rule_sets_file_paths.iter().enumerate() {
113            let joined = Path::new(relative_dir_path.as_str()).join(rule_set_file_path);
114            let path_str = joined.to_str().ok_or_else(|| ConfigError::ConfigRead {
115                path: joined.clone(),
116                source: std::io::Error::new(
117                    std::io::ErrorKind::InvalidData,
118                    format!(
119                        "rule set #{} path contains non-UTF-8 bytes: {}",
120                        rule_set_idx + 1,
121                        joined.to_string_lossy(),
122                    ),
123                ),
124            })?;
125
126            // `RuleSet::new` returns `RoutingError`; `ConfigError::RuleSet`
127            // converts via `#[from]`.
128            rule_sets.push(RuleSet::new(
129                path_str,
130                relative_dir_path.as_str(),
131                rule_set_idx,
132            )?);
133        }
134
135        self.service.rule_sets = rule_sets;
136        Ok(())
137    }
138
139    /// Resolve the fallback respond dir against the config file's parent
140    /// directory. See the module doc for why we don't resolve against CWD.
141    pub fn compute_fallback_respond_dir(
142        &mut self,
143        fallback_respond_dir_path: Option<&String>,
144    ) -> ConfigResult<()> {
145        if let Some(fallback_respond_dir_path) = fallback_respond_dir_path {
146            self.service.fallback_respond_dir = fallback_respond_dir_path.to_owned();
147            return Ok(());
148        }
149
150        if self.service.fallback_respond_dir.as_str() == SERVICE_DEFAULT_FALLBACK_RESPOND_DIR {
151            return Ok(());
152        }
153
154        let relative_path = self.current_dir_to_parent_dir_relative_path()?;
155        let joined =
156            Path::new(relative_path.as_str()).join(self.service.fallback_respond_dir.as_str());
157        let resolved = joined.to_str().ok_or_else(|| ConfigError::PathResolve {
158            path: joined.clone(),
159            source: std::io::Error::new(
160                std::io::ErrorKind::InvalidData,
161                format!(
162                    "fallback_respond_dir path contains non-UTF-8 bytes: {}",
163                    joined.to_string_lossy(),
164                ),
165            ),
166        })?;
167        self.service.fallback_respond_dir = resolved.to_owned();
168        Ok(())
169    }
170
171    /// HTTP listener address, if HTTP is enabled.
172    pub fn listener_http_addr(&self) -> Option<String> {
173        let https_is_active = self.listener_https_addr().is_some();
174        if https_is_active {
175            let port_is_single = self
176                .listener
177                .as_ref()
178                .and_then(|l| l.tls.as_ref())
179                .map(|t| t.port.is_none())
180                .unwrap_or(false);
181            if port_is_single {
182                return None;
183            }
184        }
185
186        let listener_default;
187        let listener = match self.listener.as_ref() {
188            Some(l) => l,
189            None => {
190                listener_default = ListenerConfig::default();
191                &listener_default
192            }
193        };
194
195        Some(format!("{}:{}", listener.ip_address, listener.port))
196    }
197
198    /// HTTPS listener address, if TLS is configured.
199    pub fn listener_https_addr(&self) -> Option<String> {
200        let listener = self.listener.as_ref()?;
201        let tls = listener.tls.as_ref()?;
202        let port = tls.port.unwrap_or(listener.port);
203        Some(format!("{}:{}", listener.ip_address, port))
204    }
205
206    /// Validate settings. Returns `false` when any subcomponent's
207    /// validator returned false — they log details at their own call
208    /// site so the user sees every problem in one pass.
209    fn validate(&self) -> bool {
210        if let Some(listener) = self.listener.as_ref() {
211            if !listener.validate() {
212                return false;
213            }
214
215            if self.listener_http_addr().is_none() && self.listener_https_addr().is_none() {
216                log::error!("at least one listener (http or https) is required");
217                return false;
218            }
219        }
220        self.service.validate()
221    }
222
223    /// Relative path from CWD to the parent dir of the config file.
224    pub fn current_dir_to_parent_dir_relative_path(&self) -> ConfigResult<String> {
225        let Some(file_path) = self.file_path.as_ref() else {
226            return Ok(String::from("."));
227        };
228
229        let relative_dir_path =
230            current_dir_to_file_parent_dir_relative_path(file_path.as_str()).map_err(|e| {
231                ConfigError::PathResolve {
232                    path: Path::new(file_path).to_path_buf(),
233                    source: e,
234                }
235            })?;
236
237        let as_str = relative_dir_path
238            .to_str()
239            .ok_or_else(|| ConfigError::PathResolve {
240                path: relative_dir_path.clone(),
241                source: std::io::Error::new(
242                    std::io::ErrorKind::InvalidData,
243                    format!(
244                        "relative path contains non-UTF-8 bytes: {}",
245                        relative_dir_path.to_string_lossy()
246                    ),
247                ),
248            })?;
249
250        Ok(as_str.to_owned())
251    }
252}
253
254impl Default for Config {
255    fn default() -> Self {
256        Config {
257            file_path: None,
258            listener: Some(ListenerConfig {
259                ip_address: LISTENER_DEFAULT_IP_ADDRESS.to_owned(),
260                port: LISTENER_DEFAULT_PORT,
261                tls: None,
262            }),
263            log: Some(LogConfig::default()),
264            service: ServiceConfig::default(),
265        }
266    }
267}
268
269impl std::fmt::Display for Config {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        let log = self.log.clone().unwrap_or_default();
272        let _ = write!(f, "{}", log);
273        let _ = writeln!(f, "{}", PRINT_DELIMITER);
274        let _ = write!(f, "{}", self.service);
275        Ok(())
276    }
277}