diff --git a/Cargo.toml b/Cargo.toml index f811d417..0f4899ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/examples/full/config/test/email.toml b/examples/full/config/test/email.toml new file mode 100644 index 00000000..0aff8fbd --- /dev/null +++ b/examples/full/config/test/email.toml @@ -0,0 +1,10 @@ +[email] +from = "no-reply@example.com" + +[email.smtp.connection] +# The `smtps` scheme should be used in production +uri = "smtp://localhost:1025" + +[email.sendgrid] +api-key = "api-key" +sandbox = true diff --git a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_1.snap b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_1.snap index 1c5bd121..04b1d8fe 100644 --- a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_1.snap +++ b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_1.snap @@ -1,5 +1,5 @@ --- -source: src/cli/mod.rs +source: src/api/cli/mod.rs expression: roadster_cli --- skip_validate_config = false diff --git a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_2.snap b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_2.snap index afa8eced..2fd49088 100644 --- a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_2.snap +++ b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_2.snap @@ -1,5 +1,5 @@ --- -source: src/cli/mod.rs +source: src/api/cli/mod.rs expression: roadster_cli --- environment = 'test' diff --git a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_3.snap b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_3.snap index 848d18b3..0c755b0a 100644 --- a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_3.snap +++ b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_3.snap @@ -1,5 +1,5 @@ --- -source: src/cli/mod.rs +source: src/api/cli/mod.rs expression: roadster_cli --- skip_validate_config = true diff --git a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_4.snap b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_4.snap index e5fc8542..ab11af3c 100644 --- a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_4.snap +++ b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@case_4.snap @@ -1,5 +1,5 @@ --- -source: src/cli/mod.rs +source: src/api/cli/mod.rs expression: roadster_cli --- skip_validate_config = false diff --git a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@list_routes.snap b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@list_routes.snap index 66c47d44..9b5d9f8c 100644 --- a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@list_routes.snap +++ b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@list_routes.snap @@ -1,5 +1,5 @@ --- -source: src/cli/mod.rs +source: src/api/cli/mod.rs expression: roadster_cli --- skip_validate_config = false diff --git a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@migrate.snap b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@migrate.snap index 68ab86da..66d281d4 100644 --- a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@migrate.snap +++ b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@migrate.snap @@ -1,5 +1,5 @@ --- -source: src/cli/mod.rs +source: src/api/cli/mod.rs expression: roadster_cli --- skip_validate_config = false diff --git a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@open_api.snap b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@open_api.snap index 694a02bb..5c35cf20 100644 --- a/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@open_api.snap +++ b/src/api/cli/snapshots/roadster__api__cli__tests__parse_cli@open_api.snap @@ -1,5 +1,5 @@ --- -source: src/cli/mod.rs +source: src/api/cli/mod.rs expression: roadster_cli --- skip_validate_config = false diff --git a/src/config/environment.rs b/src/config/environment.rs index c779dba5..7a22ff88 100644 --- a/src/config/environment.rs +++ b/src/config/environment.rs @@ -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> = 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("".to_string()), + ] + }) + } + + fn from_str_impl(input: &str, ignore_case: bool) -> Result { + 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 { + 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::from_str_impl(input, ignore_case) + } + + fn to_possible_value(&self) -> Option { + 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 { + 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 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"; @@ -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 = ::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); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index d84cccd1..6b0975b9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -108,7 +108,8 @@ 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/")) @@ -116,7 +117,7 @@ impl AppConfig { 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); diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_01.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_01.snap new file mode 100644 index 00000000..35577638 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_01.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +Development diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_02.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_02.snap new file mode 100644 index 00000000..35577638 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_02.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +Development diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_03.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_03.snap new file mode 100644 index 00000000..09c7776c --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_03.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +Test diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_04.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_04.snap new file mode 100644 index 00000000..d9ad4800 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_04.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +Production diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_05.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_05.snap new file mode 100644 index 00000000..d9ad4800 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_05.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +Production diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_06.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_06.snap new file mode 100644 index 00000000..0362257f --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_06.snap @@ -0,0 +1,7 @@ +--- +source: src/config/environment.rs +expression: env +--- +Custom( + "custom-environment", +) diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_07.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_07.snap new file mode 100644 index 00000000..35577638 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_07.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +Development diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_08.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_08.snap new file mode 100644 index 00000000..09c7776c --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_08.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +Test diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_09.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_09.snap new file mode 100644 index 00000000..d9ad4800 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_09.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +Production diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_10.snap b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_10.snap new file mode 100644 index 00000000..393dec1b --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_from_str@case_10.snap @@ -0,0 +1,7 @@ +--- +source: src/config/environment.rs +expression: env +--- +Custom( + "CUSTOM-ENVIRONMENT", +) diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_1.snap b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_1.snap new file mode 100644 index 00000000..ad2588b2 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_1.snap @@ -0,0 +1,7 @@ +--- +source: src/config/environment.rs +expression: env +--- +{ + "env": "development" +} diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_2.snap b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_2.snap new file mode 100644 index 00000000..cc6e864b --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_2.snap @@ -0,0 +1,7 @@ +--- +source: src/config/environment.rs +expression: env +--- +{ + "env": "test" +} diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_3.snap b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_3.snap new file mode 100644 index 00000000..418ded58 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_3.snap @@ -0,0 +1,7 @@ +--- +source: src/config/environment.rs +expression: env +--- +{ + "env": "production" +} diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_4.snap b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_4.snap new file mode 100644 index 00000000..e63f6a67 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_json@case_4.snap @@ -0,0 +1,7 @@ +--- +source: src/config/environment.rs +expression: env +--- +{ + "env": "custom-environment" +} diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_1.snap b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_1.snap new file mode 100644 index 00000000..3075f1c2 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_1.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +env = 'development' diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_2.snap b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_2.snap new file mode 100644 index 00000000..f9b61269 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_2.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +env = 'test' diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_3.snap b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_3.snap new file mode 100644 index 00000000..4ee0f8b5 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_3.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +env = 'production' diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_4.snap b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_4.snap new file mode 100644 index 00000000..19390693 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_serialize_toml@case_4.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +env = 'custom-environment' diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_1.snap b/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_1.snap new file mode 100644 index 00000000..21ba63e3 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_1.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +"development" diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_2.snap b/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_2.snap new file mode 100644 index 00000000..26e2a728 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_2.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +"test" diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_3.snap b/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_3.snap new file mode 100644 index 00000000..97d8a89a --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_3.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +"production" diff --git a/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_4.snap b/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_4.snap new file mode 100644 index 00000000..01797ae0 --- /dev/null +++ b/src/config/snapshots/roadster__config__environment__tests__environment_to_string@case_4.snap @@ -0,0 +1,5 @@ +--- +source: src/config/environment.rs +expression: env +--- +"custom-environment"