Skip to content

Commit

Permalink
enable optional default for configuration (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
FaveroFerreira authored Sep 16, 2023
1 parent 23e3e62 commit 919c90d
Show file tree
Hide file tree
Showing 9 changed files with 313 additions and 190 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

# 1.1.0

- Add support for optional default in `Toml` files

# 1.0.1

- Fix MSRV to 1.66.1
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "konfiguration"
version = "1.0.1"
version = "1.1.0"
rust-version = "1.66.1"
description = "Toml configuration loader with environment variables support."
documentation = "https://docs.rs/konfiguration/"
Expand All @@ -10,7 +10,7 @@ readme = "README.md"
keywords = ["config", "configuration", "settings", "env", "environment"]
authors = ["Guilherme Favero Ferreira <[email protected]>"]
categories = ["config"]
license = "MIT/Apache-2.0"
license = "MIT"
edition = "2021"

[badges]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub struct PostgresConfig {
}
fn main() {
let config = Konfiguration::from_file("filepath/config.json")
let config = Konfiguration::from_file("filepath/config.toml")
.parse::<Config>()
.unwrap();
Expand Down
31 changes: 24 additions & 7 deletions src/de.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ use serde::de;
use serde_untagged::de::{Map, Seq};
use serde_untagged::UntaggedEnumVisitor;

use crate::value::{ConfigurationEntry, DetailedConfigurationEntry, TomlValue};
use crate::value::{ConfigurationEntry, TomlValue};

/// Enables deserialization of a configuration entry.
/// Enables deserialization of a configuration entry from a Toml file.
///
/// We need a custom implementation because we want to support both simple and
/// detailed configuration entries. A simple configuration entry is just a
Expand All @@ -29,6 +29,7 @@ impl<'de> de::Deserialize<'de> for ConfigurationEntry {
.u32(ConfigurationEntry::try_from)
.f32(ConfigurationEntry::try_from)
.f64(ConfigurationEntry::try_from)
.bool(ConfigurationEntry::try_from)
.string(|s| {
ConfigurationEntry::try_from(s)
})
Expand All @@ -42,6 +43,14 @@ impl<'de> de::Deserialize<'de> for ConfigurationEntry {
}
}

impl TryFrom<bool> for ConfigurationEntry {
type Error = serde_untagged::de::Error;

fn try_from(value: bool) -> Result<Self, Self::Error> {
Ok(ConfigurationEntry::Simple(TomlValue::Boolean(value)))
}
}

impl TryFrom<i8> for ConfigurationEntry {
type Error = serde_untagged::de::Error;

Expand Down Expand Up @@ -140,11 +149,19 @@ impl TryFrom<Map<'_, '_>> for ConfigurationEntry {
fn try_from(value: Map) -> Result<Self, Self::Error> {
let toml_map: toml::map::Map<String, TomlValue> = value.deserialize()?;

if let (Some(env), Some(default)) = (toml_map.get("env"), toml_map.get("default")) {
Ok(ConfigurationEntry::Detailed(DetailedConfigurationEntry {
env: env.as_str().map(|s| s.to_string()),
default: default.clone(),
}))
if let Some(env) = toml_map.get("env") {
let env_name = env
.as_str()
.ok_or_else(|| de::Error::custom("env name must be a string"))?;

let env_val = std::env::var(env_name).ok();
let default = toml_map.get("default").cloned();

match (env_val, default) {
(Some(env_val), _) => Ok(ConfigurationEntry::Env(env_val)),
(None, Some(default)) => Ok(ConfigurationEntry::Simple(default)),
(None, None) => Ok(ConfigurationEntry::UnsetEnv),
}
} else {
let str = toml::to_string(&toml_map).map_err(|e| de::Error::custom(e.to_string()))?;

Expand Down
18 changes: 16 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use std::fmt::Display;

#[derive(Debug, thiserror::Error)]
pub enum Error {
pub enum KonfigurationError {
#[error("failed to load configuration file: {0}")]
Io(#[from] std::io::Error),

Expand All @@ -8,6 +10,18 @@ pub enum Error {

#[error("failed to parse configuration entry: {0}")]
Entry(String),

#[error("failed to deserialize configuration: {0}")]
Deserialization(#[from] serde_untagged::de::Error),
}

impl serde::de::Error for KonfigurationError {
fn custom<T>(msg: T) -> Self
where
T: Display,
{
KonfigurationError::Entry(msg.to_string())
}
}

pub type KonfigurationResult<T> = Result<T, Error>;
pub type KonfigurationResult<T> = Result<T, KonfigurationError>;
168 changes: 22 additions & 146 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ use std::fs;
use serde::Deserialize;
use toml::de::ValueDeserializer;

use crate::error::{Error, KonfigurationResult};
use crate::value::{
ConfigurationEntry, ConfigurationManifest, DetailedConfigurationEntry, TomlMap, TomlValue,
};
use crate::error::KonfigurationResult;
use crate::value::{ConfigurationEntry, ConfigurationManifest, TomlMap, TomlValue};

mod de;
pub mod error;
Expand All @@ -16,7 +14,7 @@ mod value;
///
/// # Examples
///
/// ```rust
/// ```no_run
/// use konfiguration::Konfiguration;
///
/// #[derive(Debug, serde::Deserialize)]
Expand All @@ -40,12 +38,14 @@ pub struct Konfiguration {
}

impl Konfiguration {
/// Creates a new configuration loader with the given file path.
pub fn from_file(path: impl Into<String>) -> Self {
Konfiguration {
file_path: path.into(),
}
}

/// Parses the configuration file into the given type.
pub fn parse<T: serde::de::DeserializeOwned>(self) -> KonfigurationResult<T> {
let text = fs::read_to_string(self.file_path)?;
let manifest = toml::from_str::<ConfigurationManifest>(&text)?;
Expand All @@ -62,7 +62,11 @@ fn simplify(manifest: ConfigurationManifest) -> KonfigurationResult<TomlMap> {
for (key, config_entry) in manifest {
let value = match config_entry {
ConfigurationEntry::Simple(value) => value,
ConfigurationEntry::Detailed(detailed) => expand_env_var(detailed)?,
ConfigurationEntry::Env(env_value) => {
env_sanity_check(&env_value);
expand_with_retry(env_value)?
}
ConfigurationEntry::UnsetEnv => continue,
ConfigurationEntry::Table(table) => {
let simplified = simplify(table)?;

Expand All @@ -76,148 +80,20 @@ fn simplify(manifest: ConfigurationManifest) -> KonfigurationResult<TomlMap> {
Ok(map)
}

/// Expands an `DetailedConfigurationEntry` into a TOML value.
///
/// If the `env` field is `None`, the `default` field is returned.
fn expand_env_var(entry: DetailedConfigurationEntry) -> KonfigurationResult<TomlValue> {
let DetailedConfigurationEntry { env, default } = entry;

let Some(env) = env else {
return Ok(default);
};

let Some(override_value) = std::env::var(env).ok() else {
return Ok(default)
};

// Ugly stuff to make sure we can parse the value into the correct type.
match default {
TomlValue::String(_) => to_toml_string(&override_value),
TomlValue::Integer(_) => to_toml_integer(&override_value),
TomlValue::Float(_) => to_toml_float(&override_value),
TomlValue::Boolean(_) => to_toml_boolean(&override_value),
TomlValue::Datetime(_) => to_toml_date_time(&override_value),
TomlValue::Array(_) => to_toml_array(&override_value),
TomlValue::Table(_) => {
unreachable!("this should be handled by the table stuff, how did it get here?");
}
/// Not much to do here at this point, but we might want to add more checks in the future.
fn env_sanity_check(env: &str) {
if env.is_empty() {
panic!("env cannot be empty");
}
}

/// Converts a string into a TOML array.
fn to_toml_array(value: &str) -> KonfigurationResult<TomlValue> {
Ok(TomlValue::deserialize(ValueDeserializer::new(value))?)
}

/// Converts a string into a TOML boolean.
fn to_toml_boolean(value: &str) -> KonfigurationResult<TomlValue> {
value
.parse()
.map(TomlValue::Boolean)
.map_err(|e| Error::Entry(e.to_string()))
}

/// Converts a string into a TOML date time.
fn to_toml_date_time(value: &str) -> KonfigurationResult<TomlValue> {
value
.parse()
.map(TomlValue::Datetime)
.map_err(|e| Error::Entry(e.to_string()))
}

/// Converts a string into a TOML string.
fn to_toml_string(value: &str) -> KonfigurationResult<TomlValue> {
Ok(TomlValue::String(value.to_string()))
}

/// Converts a string into a TOML integer.
fn to_toml_integer(value: &str) -> KonfigurationResult<TomlValue> {
value
.parse()
.map(TomlValue::Integer)
.map_err(|e| Error::Entry(e.to_string()))
}

/// Converts a string into a TOML float.
fn to_toml_float(value: &str) -> KonfigurationResult<TomlValue> {
value
.parse()
.map(TomlValue::Float)
.map_err(|e| Error::Entry(e.to_string()))
}

#[cfg(test)]
mod tests {
use serde::Deserialize;

use super::*;

#[derive(Debug, Deserialize)]
pub struct Config {
pub profile: String,
pub rust_log: String,
pub cors_origin: String,
pub server_port: u16,
pub exponential_backoff: Vec<u16>,
pub mail: MailConfig,
pub postgres: PostgresConfig,
pub redis: RedisConfig,
}

#[derive(Debug, Deserialize)]
pub struct RedisConfig {
pub url: String,
pub max_connections: u32,
pub min_connections: u32,
pub connection_acquire_timeout_secs: u64,
}

#[derive(Debug, Deserialize)]
pub struct PostgresConfig {
pub host: String,
pub username: String,
pub password: String,
pub database: String,
pub port: u16,
pub min_connections: u32,
pub max_connections: u32,
pub connection_acquire_timeout_secs: u64,
pub enable_migration: bool,
pub migrations_dir: String,
}

#[derive(Debug, Deserialize)]
pub struct MailConfig {
pub from: String,
pub templates_dir: String,
pub smtp: SmtpConfig,
}

#[derive(Debug, Deserialize)]
pub struct SmtpConfig {
pub host: String,
pub username: String,
pub password: String,
}

#[test]
fn can_parse_configs() {
std::env::set_var("EXPONENTIAL_BACKOFF", "[3, 4, 5]");
std::env::set_var("PROFILE", "prod");
std::env::set_var("SMTP_PASSWORD", "password");
std::env::set_var("DATABASE_PORT", "1111");

let config = Konfiguration::from_file("test_files/config.toml")
.parse::<Config>()
.unwrap();

assert_eq!(config.profile, "prod");
assert_eq!(config.rust_log, "info");
assert_eq!(config.cors_origin, "*");
assert_eq!(config.server_port, 8080);
assert_eq!(config.exponential_backoff, vec![3, 4, 5]);
assert_eq!(config.mail.templates_dir, "templates/**/*.html");
assert_eq!(config.postgres.port, 1111);
assert_eq!(config.mail.smtp.password, "password");
/// Expands an env var value into into a TOML value.
///
/// This is ugly because toml sometimes fails to deserialize a simple string
/// I will be looking into this later.
fn expand_with_retry(value: String) -> KonfigurationResult<TomlValue> {
match TomlValue::deserialize(ValueDeserializer::new(&value)) {
Ok(v) => Ok(v),
Err(_) => Ok(TomlValue::String(value)),
}
}
7 changes: 4 additions & 3 deletions src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ pub type ConfigurationManifest = HashMap<String, ConfigurationEntry>;
#[derive(Debug)]
pub enum ConfigurationEntry {
Simple(TomlValue),
Detailed(DetailedConfigurationEntry),
Env(String),
UnsetEnv,
Table(HashMap<String, ConfigurationEntry>),
}

#[derive(Debug)]
pub struct DetailedConfigurationEntry {
pub env: Option<String>,
pub default: TomlValue,
pub env_val: String,
pub default: Option<TomlValue>,
}
Loading

0 comments on commit 919c90d

Please sign in to comment.