Skip to content

Commit

Permalink
Allow partial overrides of all configs
Browse files Browse the repository at this point in the history
The app config defines defaults for a lot of things. However,
if the user provides a field, sometimes that means the defaults
for the rest of that sub-config don’t use the default and need
to be set by the user. This is especially difficult to work
around for fields that re-use the same struct.

Refactor how the app config fields that share a common struct are
contructed to provide a different default for each use of the struct,
while still allowing the user to partially override individual fields of
the config.

Also, add tests for all (most?) of the app config to ensure this
behavior is not broken in the future.

Closes #152
  • Loading branch information
spencewenski committed May 24, 2024
1 parent 31b1828 commit 1d3a719
Show file tree
Hide file tree
Showing 52 changed files with 1,950 additions and 441 deletions.
1 change: 1 addition & 0 deletions examples/minimal/config/default.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[app]
name = "Minimal Example"

[tracing]
level = "debug"

Expand Down
106 changes: 8 additions & 98 deletions src/config/app_config.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
use crate::config::auth::Auth;
#[cfg(feature = "db-sql")]
use crate::config::database::Database;
use crate::config::environment::{Environment, ENVIRONMENT_ENV_VAR_NAME};
use crate::config::service::Service;
use crate::util::serde_util::{default_true, UriOrString};
use crate::config::tracing::Tracing;
use crate::util::serde_util::default_true;
use anyhow::anyhow;
use config::{Case, Config};
use dotenvy::dotenv;
use serde_derive::{Deserialize, Serialize};
use serde_json::Value;
#[cfg(feature = "db-sql")]
use serde_with::serde_as;
use std::collections::BTreeMap;
#[cfg(feature = "db-sql")]
use std::time::Duration;
use tracing::warn;
#[cfg(any(feature = "otel", feature = "db-sql"))]
use url::Url;
use validator::Validate;

pub type CustomConfig = BTreeMap<String, Value>;

#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct AppConfig {
Expand Down Expand Up @@ -117,7 +117,7 @@ impl AppConfig {
port = 3000
[service.sidekiq.redis]
uri = "redis://localhost:6379"
uri = "redis://invalid_host:1234"
"#,
);

Expand Down Expand Up @@ -145,96 +145,6 @@ pub struct App {
pub shutdown_on_error: bool,
}

#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Auth {
#[validate(nested)]
pub jwt: Jwt,
}

#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Jwt {
pub secret: String,
#[serde(default)]
#[validate(nested)]
pub claims: JwtClaims,
}

#[derive(Debug, Clone, Validate, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct JwtClaims {
// Todo: Default to the server URL?
pub audience: Vec<UriOrString>,
/// Claim names to require, in addition to the default-required `exp` claim.
pub required_claims: Vec<String>,
}

#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Tracing {
pub level: String,

/// The name of the service to use for the OpenTelemetry `service.name` field. If not provided,
/// will use the [`App::name`][App] config value, translated to `snake_case`.
#[cfg(feature = "otel")]
pub service_name: Option<String>,

/// Propagate traces across service boundaries. Mostly useful in microservice architectures.
#[serde(default = "default_true")]
#[cfg(feature = "otel")]
pub trace_propagation: bool,

/// URI of the OTLP exporter where traces/metrics/logs will be sent.
#[cfg(feature = "otel")]
pub otlp_endpoint: Option<Url>,
}

#[cfg(feature = "db-sql")]
#[serde_as]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Database {
/// This can be overridden with an environment variable, e.g. `ROADSTER.DATABASE.URI=postgres://example:example@example:1234/example_app`
pub uri: Url,
/// Whether to automatically apply migrations during the app's start up. Migrations can also
/// be manually performed via the `roadster migration [COMMAND]` CLI command.
pub auto_migrate: bool,
#[serde(default = "Database::default_connect_timeout")]
#[serde_as(as = "serde_with::DurationMilliSeconds")]
pub connect_timeout: Duration,
#[serde(default = "Database::default_acquire_timeout")]
#[serde_as(as = "serde_with::DurationMilliSeconds")]
pub acquire_timeout: Duration,
#[serde_as(as = "Option<serde_with::DurationSeconds>")]
pub idle_timeout: Option<Duration>,
#[serde_as(as = "Option<serde_with::DurationSeconds>")]
pub max_lifetime: Option<Duration>,
#[serde(default)]
pub min_connections: u32,
pub max_connections: u32,
}

#[cfg(feature = "db-sql")]
impl Database {
fn default_connect_timeout() -> Duration {
Duration::from_millis(1000)
}

fn default_acquire_timeout() -> Duration {
Duration::from_millis(1000)
}
}

/// General struct to capture custom config values that don't exist in a pre-defined
/// config struct.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct CustomConfig {
#[serde(flatten)]
pub config: BTreeMap<String, Value>,
}

#[cfg(test)]
mod tests {
use crate::config::app_config::AppConfig;
Expand Down
83 changes: 83 additions & 0 deletions src/config/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use crate::util::serde_util::UriOrString;
use serde_derive::{Deserialize, Serialize};
use validator::Validate;

#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Auth {
#[validate(nested)]
pub jwt: Jwt,
}

#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Jwt {
pub secret: String,
#[serde(default)]
#[validate(nested)]
pub claims: JwtClaims,
}

#[derive(Debug, Clone, Default, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct JwtClaims {
// Todo: Default to the server URL?
#[serde(default)]
pub audience: Vec<UriOrString>,
/// Claim names to require, in addition to the default-required `exp` claim.
#[serde(default)]
pub required_claims: Vec<String>,
}

#[cfg(test)]
mod tests {
use super::*;
use crate::util::test_util::TestCase;
use insta::assert_toml_snapshot;
use rstest::{fixture, rstest};

#[fixture]
#[cfg_attr(coverage_nightly, coverage(off))]
fn case() -> TestCase {
Default::default()
}

#[rstest]
#[case(
r#"
[jwt]
secret = "foo"
"#
)]
#[case(
r#"
[jwt]
secret = "foo"
[jwt.claims]
audience = ["bar"]
"#
)]
#[case(
r#"
[jwt]
secret = "foo"
[jwt.claims]
required-claims = ["baz"]
"#
)]
#[case(
r#"
[jwt]
secret = "foo"
[jwt.claims]
audience = ["bar"]
required-claims = ["baz"]
"#
)]
#[cfg_attr(coverage_nightly, coverage(off))]
fn auth(_case: TestCase, #[case] config: &str) {
let auth: Auth = toml::from_str(config).unwrap();

assert_toml_snapshot!(auth);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: src/config/auth/mod.rs
expression: auth
---
[jwt]
secret = 'foo'

[jwt.claims]
audience = []
required-claims = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: src/config/auth/mod.rs
expression: auth
---
[jwt]
secret = 'foo'

[jwt.claims]
audience = ['bar']
required-claims = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: src/config/auth/mod.rs
expression: auth
---
[jwt]
secret = 'foo'

[jwt.claims]
audience = []
required-claims = ['baz']
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: src/config/auth/mod.rs
expression: auth
---
[jwt]
secret = 'foo'

[jwt.claims]
audience = ['bar']
required-claims = ['baz']
79 changes: 79 additions & 0 deletions src/config/database.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
use serde_derive::{Deserialize, Serialize};
use serde_with::serde_as;
use std::time::Duration;
use url::Url;
use validator::Validate;

#[serde_as]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Database {
/// This can be overridden with an environment variable, e.g. `ROADSTER.DATABASE.URI=postgres://example:example@example:1234/example_app`
pub uri: Url,
/// Whether to automatically apply migrations during the app's start up. Migrations can also
/// be manually performed via the `roadster migration [COMMAND]` CLI command.
pub auto_migrate: bool,
#[serde(default = "Database::default_connect_timeout")]
#[serde_as(as = "serde_with::DurationMilliSeconds")]
pub connect_timeout: Duration,
#[serde(default = "Database::default_acquire_timeout")]
#[serde_as(as = "serde_with::DurationMilliSeconds")]
pub acquire_timeout: Duration,
#[serde_as(as = "Option<serde_with::DurationSeconds>")]
pub idle_timeout: Option<Duration>,
#[serde_as(as = "Option<serde_with::DurationSeconds>")]
pub max_lifetime: Option<Duration>,
#[serde(default)]
pub min_connections: u32,
pub max_connections: u32,
}

impl Database {
fn default_connect_timeout() -> Duration {
Duration::from_millis(1000)
}

fn default_acquire_timeout() -> Duration {
Duration::from_millis(1000)
}
}

#[cfg(test)]
mod deserialize_tests {
use super::*;
use crate::util::test_util::TestCase;
use insta::assert_toml_snapshot;
use rstest::{fixture, rstest};

#[fixture]
#[cfg_attr(coverage_nightly, coverage(off))]
fn case() -> TestCase {
Default::default()
}

#[rstest]
#[case(
r#"
uri = "https://example.com:1234"
auto-migrate = true
max-connections = 1
"#
)]
#[case(
r#"
uri = "https://example.com:1234"
auto-migrate = true
max-connections = 1
connect-timeout = 1000
acquire-timeout = 2000
idle-timeout = 3000
max-lifetime = 4000
"#
)]
#[cfg_attr(coverage_nightly, coverage(off))]
fn sidekiq(_case: TestCase, #[case] config: &str) {
let database: Database = toml::from_str(config).unwrap();

assert_toml_snapshot!(database);
}
}
4 changes: 4 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
pub mod app_config;
pub mod auth;
#[cfg(feature = "db-sql")]
pub mod database;
pub mod environment;
pub mod service;
pub mod tracing;
Loading

0 comments on commit 1d3a719

Please sign in to comment.