Skip to content

Commit

Permalink
feat: Enable consumers to provide custom Environment values (#439)
Browse files Browse the repository at this point in the history
Consumers may want to create custom enviornments, e.g. `gamma` or
`staging`.

Add a `Environment::Custom` enum variant. The custom value is contained
in a String in the variant.

Note: A future release may remove the `From<Environment> for &'static
str` and `From<&Environment> for &'static str` implementations because
it's not possible to convert `Environment::Custom` to a static str. It's
kept for now because it was implemented before we added the
`Environment::Custom` variant.

Closes #438
  • Loading branch information
spencewenski authored Oct 16, 2024
1 parent 0f378e3 commit 8d3e8a1
Show file tree
Hide file tree
Showing 33 changed files with 364 additions and 14 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ reqwest = { workspace = true }

[dev-dependencies]
cargo-husky = { version = "1.5.0", default-features = false, features = ["user-hooks"] }
insta = { workspace = true }
insta = { workspace = true, features = ["json"] }
mockall = "0.13.0"
mockall_double = "0.3.1"
rstest = { workspace = true }
Expand Down
10 changes: 10 additions & 0 deletions examples/full/config/test/email.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[email]
from = "[email protected]"

[email.smtp.connection]
# The `smtps` scheme should be used in production
uri = "smtp://localhost:1025"

[email.sendgrid]
api-key = "api-key"
sandbox = true
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
source: src/cli/mod.rs
source: src/api/cli/mod.rs
expression: roadster_cli
---
skip_validate_config = false
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
source: src/cli/mod.rs
source: src/api/cli/mod.rs
expression: roadster_cli
---
environment = 'test'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
source: src/cli/mod.rs
source: src/api/cli/mod.rs
expression: roadster_cli
---
skip_validate_config = true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
source: src/cli/mod.rs
source: src/api/cli/mod.rs
expression: roadster_cli
---
skip_validate_config = false
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
source: src/cli/mod.rs
source: src/api/cli/mod.rs
expression: roadster_cli
---
skip_validate_config = false
Expand Down
2 changes: 1 addition & 1 deletion src/api/cli/snapshots/[email protected]
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
source: src/cli/mod.rs
source: src/api/cli/mod.rs
expression: roadster_cli
---
skip_validate_config = false
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
source: src/cli/mod.rs
source: src/api/cli/mod.rs
expression: roadster_cli
---
skip_validate_config = false
Expand Down
225 changes: 221 additions & 4 deletions src/config/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,153 @@ use crate::config::{ENV_VAR_PREFIX, ENV_VAR_SEPARATOR};
use crate::error::RoadsterResult;
use anyhow::anyhow;
#[cfg(feature = "cli")]
use clap::builder::PossibleValue;
#[cfg(feature = "cli")]
use clap::ValueEnum;
use const_format::concatcp;
use serde_derive::{Deserialize, Serialize};
use std::env;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use strum_macros::{EnumString, IntoStaticStr};
use std::sync::OnceLock;

#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, EnumString, IntoStaticStr)]
#[cfg_attr(feature = "cli", derive(ValueEnum))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
#[non_exhaustive]
pub enum Environment {
Development,
Test,
Production,
#[serde(untagged)]
Custom(String),
}

static ENV_VARIANTS: OnceLock<Vec<Environment>> = OnceLock::new();

const DEVELOPMENT: &str = "development";
const TEST: &str = "test";
const PRODUCTION: &str = "production";

impl Environment {
fn value_variants_impl<'a>() -> &'a [Self] {
ENV_VARIANTS.get_or_init(|| {
vec![
Environment::Development,
Environment::Test,
Environment::Production,
Environment::Custom("<custom>".to_string()),
]
})
}

fn from_str_impl(input: &str, ignore_case: bool) -> Result<Self, String> {
let env = Self::value_variants_impl()
.iter()
.find(|variant| {
let values = variant.to_possible_value_impl();
if ignore_case {
values
.iter()
.any(|value| value.to_lowercase() == input.to_lowercase())
} else {
values.iter().any(|value| value == input)
}
})
.cloned()
.unwrap_or_else(|| Environment::Custom(input.to_string()))
.clone();

Ok(env)
}

fn to_possible_value_impl(&self) -> Vec<String> {
match self {
Environment::Development => vec![DEVELOPMENT.to_string(), "dev".to_string()],
Environment::Test => vec![TEST.to_string()],
Environment::Production => vec![PRODUCTION.to_string(), "prod".to_string()],
Environment::Custom(custom) => vec![custom.to_string()],
}
}
}

// We need to manually implement (vs. deriving) `ValueEnum` in order to support the
// `Environment::Custom` variant.
#[cfg(feature = "cli")]
impl ValueEnum for Environment {
fn value_variants<'a>() -> &'a [Self] {
Self::value_variants_impl()
}

fn from_str(input: &str, ignore_case: bool) -> Result<Self, String> {
Self::from_str_impl(input, ignore_case)
}

fn to_possible_value(&self) -> Option<PossibleValue> {
let values = self.to_possible_value_impl();
values
.first()
.map(PossibleValue::new)
.map(|possible_value| possible_value.aliases(&values[1..]))
}
}

// We need to manually implement `Display` (vs. deriving `IntoStaticStr` from `strum`) in order to
// support the `Environment::Custom` variant.
impl Display for Environment {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Environment::Development => {
write!(f, "{DEVELOPMENT}")
}
Environment::Test => {
write!(f, "{TEST}")
}
Environment::Production => {
write!(f, "{PRODUCTION}")
}
Environment::Custom(custom) => {
write!(f, "{custom}")
}
}
}
}

// We need to manually implement `FromStr` (vs. deriving `EnumString` from `strum`) in order to
// support the `Environment::Custom` variant.
impl FromStr for Environment {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let env = Self::from_str_impl(s, true)?;
Ok(env)
}
}

/// Note: A future release may remove this implementation because it's not possible to convert
/// `Environment::Custom` to a static str. It's kept for now because it was implemented before
/// we added the `Environment::Custom` variant.
// todo: remove this implementation in a semver breaking version bump
impl From<Environment> for &'static str {
fn from(value: Environment) -> Self {
(&value).into()
}
}

/// Note: A future release may remove this implementation because it's not possible to convert
/// `Environment::Custom` to a static str. It's kept for now because it was implemented before
/// we added the `Environment::Custom` variant.
// todo: remove this implementation in a semver breaking version bump
impl From<&Environment> for &'static str {
fn from(value: &Environment) -> Self {
match value {
Environment::Development => DEVELOPMENT,
Environment::Test => TEST,
Environment::Production => PRODUCTION,
Environment::Custom(_) => {
unimplemented!("It's not possible to convert `Environment::Custom` to a static str. Use ToString/Display instead.")
}
}
}
}

pub(crate) const ENVIRONMENT_ENV_VAR_NAME: &str = "ENVIRONMENT";
Expand All @@ -42,3 +173,89 @@ impl Environment {
Ok(environment)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::testing::snapshot::TestCase;
use insta::{assert_debug_snapshot, assert_json_snapshot, assert_toml_snapshot};
use rstest::{fixture, rstest};

#[fixture]
fn case() -> TestCase {
Default::default()
}

#[rstest]
#[case(Environment::Development)]
#[case(Environment::Test)]
#[case(Environment::Production)]
#[case(Environment::Custom("custom-environment".to_string()))]
#[cfg_attr(coverage_nightly, coverage(off))]
fn environment_to_string(_case: TestCase, #[case] env: Environment) {
let env = env.to_string();
assert_debug_snapshot!(env);
}

#[rstest]
#[case(Environment::Development, false)]
#[case(Environment::Test, false)]
#[case(Environment::Production, false)]
#[case(Environment::Custom("custom-environment".to_string()), true)]
#[cfg_attr(coverage_nightly, coverage(off))]
fn environment_to_static_str(
_case: TestCase,
#[case] env: Environment,
#[case] expect_error: bool,
) {
let env = std::panic::catch_unwind(|| {
let env: &str = env.into();
env
});
assert_eq!(env.is_err(), expect_error);
}

#[rstest]
#[case(DEVELOPMENT.to_string())]
#[case("dev".to_string())]
#[case(TEST.to_string())]
#[case(PRODUCTION.to_string())]
#[case("prod".to_string())]
#[case("custom-environment".to_string())]
#[case(DEVELOPMENT.to_uppercase())]
#[case(TEST.to_uppercase())]
#[case(PRODUCTION.to_uppercase())]
#[case("custom-environment".to_uppercase())]
#[cfg_attr(coverage_nightly, coverage(off))]
fn environment_from_str(_case: TestCase, #[case] env: String) {
let env = <Environment as FromStr>::from_str(&env).unwrap();
assert_debug_snapshot!(env);
}

#[derive(Debug, Serialize, Deserialize)]
struct Wrapper {
env: Environment,
}

#[rstest]
#[case(Environment::Development)]
#[case(Environment::Test)]
#[case(Environment::Production)]
#[case(Environment::Custom("custom-environment".to_string()))]
#[cfg_attr(coverage_nightly, coverage(off))]
fn environment_serialize_json(_case: TestCase, #[case] env: Environment) {
let env = Wrapper { env };
assert_json_snapshot!(env);
}

#[rstest]
#[case(Environment::Development)]
#[case(Environment::Test)]
#[case(Environment::Production)]
#[case(Environment::Custom("custom-environment".to_string()))]
#[cfg_attr(coverage_nightly, coverage(off))]
fn environment_serialize_toml(_case: TestCase, #[case] env: Environment) {
let env = Wrapper { env };
assert_toml_snapshot!(env);
}
}
5 changes: 3 additions & 2 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,16 @@ impl AppConfig {
} else {
Environment::new()?
};
let environment_str: &str = environment.clone().into();
let environment_string = environment.clone().to_string();
let environment_str = environment_string.as_str();

let config_root_dir = config_dir
.unwrap_or_else(|| PathBuf::from("config/"))
.canonicalize()?;

println!("Loading configuration from directory {config_root_dir:?}");

let config = Self::default_config(environment);
let config = Self::default_config(environment.clone());
let config = config_env_file("default", &config_root_dir, config);
let config = config_env_dir("default", &config_root_dir, config)?;
let config = config_env_file(environment_str, &config_root_dir, config);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/config/environment.rs
expression: env
---
Development
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/config/environment.rs
expression: env
---
Development
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/config/environment.rs
expression: env
---
Test
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/config/environment.rs
expression: env
---
Production
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/config/environment.rs
expression: env
---
Production
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: src/config/environment.rs
expression: env
---
Custom(
"custom-environment",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/config/environment.rs
expression: env
---
Development
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/config/environment.rs
expression: env
---
Test
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/config/environment.rs
expression: env
---
Production
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: src/config/environment.rs
expression: env
---
Custom(
"CUSTOM-ENVIRONMENT",
)
Loading

0 comments on commit 8d3e8a1

Please sign in to comment.