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}