pavex/config/mod.rs
1//! Utilities to load the hierarchical configuration for a Pavex application.
2//!
3//! [`ConfigLoader`] is the key type in this module.
4//!
5//! # Guide
6//!
7//! Check out [the guide](https://pavex.dev/docs/guide/configuration/)
8//! for a thorough introduction to Pavex configuration system.
9use std::{path::PathBuf, str::FromStr};
10
11use anyhow::Context;
12use figment::{
13 Figment,
14 providers::{Env, Format, Yaml},
15};
16use serde::de::DeserializeOwned;
17
18#[derive(Clone, Debug)]
19/// A utility to load hierarchical configuration in a Pavex application.
20///
21/// Check out [`ConfigLoader::load`] for more information.
22///
23/// # Example
24///
25/// ```rust,no_run
26/// use pavex::config::{ConfigLoader, ConfigProfile};
27///
28/// #[derive(ConfigProfile, Debug, Clone, Copy, PartialEq, Eq)]
29/// pub enum Profile {
30/// #[px(profile = "dev")]
31/// Development,
32/// #[px(profile = "prod")]
33/// Production,
34/// }
35///
36/// #[derive(Debug, Clone, serde::Deserialize)]
37/// pub struct Config {
38/// database_url: String,
39/// // Other fields...
40/// }
41///
42/// # fn main() -> anyhow::Result<()> {
43/// let config: Config = ConfigLoader::<Profile>::new().load()?;
44/// # Ok(())
45/// # }
46/// ```
47pub struct ConfigLoader<Profile> {
48 configuration_dir: Option<PathBuf>,
49 profile: Option<Profile>,
50}
51
52/// A macro to derive an implementation of the [`ConfigProfile`] trait.
53///
54/// ```rust
55/// use pavex::config::ConfigProfile;
56///
57/// #[derive(ConfigProfile)]
58/// pub enum Profile {
59/// Development, // "development"
60/// Production, // "production"
61/// }
62/// ```
63///
64/// ## Usage
65///
66/// By default, each variant is converted to a **snake_case** string representation:
67///
68/// ```rust
69/// use pavex::config::ConfigProfile;
70/// use std::str::FromStr;
71///
72/// #[derive(ConfigProfile)]
73/// pub enum Profile {
74/// LocalDevelopment, // "local_development"
75/// Production, // "production"
76/// }
77///
78/// # fn main() {
79/// let p = Profile::from_str("local_development").unwrap();
80/// assert_eq!(p.as_ref(), "local_development");
81/// # }
82/// ```
83///
84/// ## Custom Profile Names
85///
86/// You can override the default representation using `#[px(profile = "...")]`:
87///
88/// ```rust
89/// use pavex::config::ConfigProfile;
90/// use std::str::FromStr;
91///
92/// #[derive(ConfigProfile)]
93/// pub enum Profile {
94/// #[px(profile = "dev")]
95/// Development,
96/// #[px(profile = "prod")]
97/// Production,
98/// }
99///
100/// # fn main() -> anyhow::Result<()> {
101/// let p = Profile::from_str("dev")?;
102/// assert_eq!(p.as_ref(), "dev");
103/// # Ok(())
104/// # }
105/// ```
106///
107///
108/// ## Limitations
109///
110/// The macro only works on enums with unit variants.
111/// Enums with fields, structs, or unions are not supported and will result in a compile-time error.
112///
113/// If you need more flexibility, consider implementing [`ConfigProfile`] manually.
114pub use pavex_macros::ConfigProfile;
115
116/// Configuration profiles are used by Pavex applications to determine
117/// which configuration file to load.
118///
119/// They are usually modeled as an enum with unit variants, one for each profile.
120///
121/// ## Deriving an implementation
122///
123/// You can automatically derive an implementation of `ConfigProfile` using the `#[derive(ConfigProfile)]` attribute.
124///
125/// ```rust
126/// use pavex::config::ConfigProfile;
127///
128/// // Equivalent to the manual implementation below!
129/// #[derive(ConfigProfile, Debug, Clone, Copy, PartialEq, Eq)]
130/// pub enum Profile {
131/// #[px(profile = "dev")]
132/// Development,
133/// #[px(profile = "prod")]
134/// Production,
135/// }
136/// ```
137///
138/// Check out the [macro documentation](derive.ConfigProfile.html) for more details.
139///
140/// ## Manual implementation
141///
142/// If you need more flexibility, you can implement `ConfigProfile` manually:
143///
144/// ```rust
145/// use pavex::config::ConfigProfile;
146///
147/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
148/// pub enum Profile {
149/// Development,
150/// Production,
151/// }
152///
153/// impl std::str::FromStr for Profile {
154/// type Err = anyhow::Error;
155///
156/// fn from_str(s: &str) -> Result<Self, Self::Err> {
157/// match s {
158/// "dev" => Ok(Profile::Development),
159/// "prod" => Ok(Profile::Production),
160/// _ => anyhow::bail!("Unknown profile: {}", s),
161/// }
162/// }
163/// }
164///
165/// impl AsRef<str> for Profile {
166/// fn as_ref(&self) -> &str {
167/// match self {
168/// Profile::Development => "dev",
169/// Profile::Production => "prod",
170/// }
171/// }
172/// }
173///
174/// impl ConfigProfile for Profile {}
175/// ```
176///
177/// The value returned by `as_ref()` is used as the name of the profile-specific configuration file.
178pub trait ConfigProfile:
179 FromStr<Err: std::fmt::Display + std::fmt::Debug + Send + Sync + 'static> + AsRef<str>
180{
181 /// Load and parse the configuration profile out of the `PX_PROFILE` environment variable.
182 fn load() -> Result<Self, errors::ConfigProfileLoadError> {
183 let profile = std::env::var(PROFILE_ENV_VAR).context(
184 "Failed to load the configuration profile: the environment variable `PX_PROFILE` is either not set or set to a value that contains invalid UTF-8"
185 ).map_err(errors::ConfigProfileLoadError)?;
186 Self::from_str(&profile).map_err(|e| anyhow::anyhow!(e).context(
187 "Failed to parse the configuration profile from the `{PROFILE_ENV_VAR}` environment variable",
188 ))
189 .map_err(errors::ConfigProfileLoadError)
190 }
191}
192
193static PROFILE_ENV_VAR: &str = "PX_PROFILE";
194
195impl<Profile> ConfigLoader<Profile>
196where
197 Profile: ConfigProfile,
198{
199 /// Initialize a new [`ConfigLoader`] instance.
200 #[allow(clippy::new_without_default)]
201 pub fn new() -> Self {
202 Self {
203 configuration_dir: None,
204 profile: None,
205 }
206 }
207
208 /// Specify the application profile manually, rather than loading it
209 /// from the `PX_PROFILE` environment variable.
210 pub fn profile(mut self, profile: Profile) -> Self {
211 self.profile = Some(profile);
212 self
213 }
214
215 /// Specify the path to the directory where configuration files are stored.
216 ///
217 /// # Relative paths
218 ///
219 /// If you provide a relative path, it will be resolved relative to the current working directory.
220 /// If it's not found there, Pavex will look for it in the parent directory, up until the root directory,
221 /// stopping at the first hit.
222 ///
223 /// # Absolute paths
224 ///
225 /// If you provide an absolute path, it will be used as is.
226 ///
227 /// # Default value
228 ///
229 /// By default, Pavex looks for configuration files under `configuration/`.
230 pub fn configuration_dir<Dir>(mut self, dir: Dir) -> Self
231 where
232 Dir: Into<PathBuf>,
233 {
234 self.configuration_dir = Some(dir.into());
235 self
236 }
237
238 /// Load the configuration for the application by merging together three sources:
239 ///
240 /// 1. Environment variables (`PX_*`)
241 /// 2. Profile-specific configuration file (`{configuration_dir}/{profile}.yml`)
242 /// 3. Base configuration file (`{configuration_dir}/base.yml`)
243 ///
244 /// The list above is ordered by precedence: environment variables take precedence
245 /// over profile-specific configuration files, which in turn take precedence
246 /// over the base configuration file.
247 ///
248 /// # Guide
249 ///
250 /// Check out [the guide](https://pavex.dev/docs/guide/configuration/loading/)
251 /// for an overview of Pavex's configuration hierarchy, as well as a detailed
252 /// explanation of the naming convention used for environment variables.
253 pub fn load<Config>(self) -> Result<Config, errors::ConfigLoadError>
254 where
255 Config: DeserializeOwned,
256 {
257 let profile = match self.profile {
258 Some(profile) => profile,
259 None => Profile::load().map_err(|e| errors::ConfigLoadError(e.into()))?,
260 };
261 let configuration_dir = self
262 .configuration_dir
263 .unwrap_or_else(|| PathBuf::from("configuration"));
264 let span = tracing::info_span!(
265 "Loading configuration",
266 configuration.directory = %configuration_dir.display(),
267 configuration.profile = %profile.as_ref(),
268 );
269 let _guard = span.enter();
270 // Load configuration files from the specified directory
271 // and return a `Config` instance.
272 let base_filepath = configuration_dir.join("base.yml");
273 let profile_filepath = configuration_dir.join(format!("{}.yml", profile.as_ref()));
274
275 let prefix = "PX_";
276 let env_source = Env::prefixed(prefix)
277 .split("__")
278 // We explicitly filter out the `PX_PROFILE` environment variable
279 // to allow users to set `#[serde(deny_unknown_fields)]` on their configuration type.
280 // Without this `ignore`, `serde` would complain about `PX_PROFILE` being unknown.
281 .ignore(&[PROFILE_ENV_VAR.strip_prefix(prefix).unwrap()]);
282 let figment = Figment::new()
283 .merge(Yaml::file(base_filepath))
284 .merge(Yaml::file(profile_filepath))
285 .merge(env_source);
286
287 let configuration: Config = figment
288 .extract()
289 .context("Failed to load hierarchical configuration")
290 .map_err(errors::ConfigLoadError)?;
291 Ok(configuration)
292 }
293}
294
295/// Errors that can occur when loading configuration.
296pub mod errors {
297 #[derive(Debug, thiserror::Error)]
298 #[error("Failed to load configuration")]
299 /// The error returned by [`ConfigLoader::load`](super::ConfigLoader::load).
300 pub struct ConfigLoadError(#[source] pub(super) anyhow::Error);
301
302 #[derive(Debug, thiserror::Error)]
303 #[error(transparent)]
304 /// The error returned by [`ConfigProfile::load`](super::ConfigProfile::load).
305 pub struct ConfigProfileLoadError(pub(super) anyhow::Error);
306}