diff --git a/examples/minimal/config/default.toml b/examples/minimal/config/default.toml index b6e28257..401d9eb6 100644 --- a/examples/minimal/config/default.toml +++ b/examples/minimal/config/default.toml @@ -1,5 +1,6 @@ [app] name = "Minimal Example" + [tracing] level = "debug" diff --git a/src/config/app_config.rs b/src/config/app_config.rs index b65e2747..6fd20959 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -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; + #[derive(Debug, Clone, Validate, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct AppConfig { @@ -117,7 +117,7 @@ impl AppConfig { port = 3000 [service.sidekiq.redis] - uri = "redis://localhost:6379" + uri = "redis://invalid_host:1234" "#, ); @@ -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, - /// Claim names to require, in addition to the default-required `exp` claim. - pub required_claims: Vec, -} - -#[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, - - /// 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, -} - -#[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")] - pub idle_timeout: Option, - #[serde_as(as = "Option")] - pub max_lifetime: Option, - #[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, -} - #[cfg(test)] mod tests { use crate::config::app_config::AppConfig; diff --git a/src/config/auth/mod.rs b/src/config/auth/mod.rs new file mode 100644 index 00000000..1381a232 --- /dev/null +++ b/src/config/auth/mod.rs @@ -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, + /// Claim names to require, in addition to the default-required `exp` claim. + #[serde(default)] + pub required_claims: Vec, +} + +#[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); + } +} diff --git a/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_1.snap b/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_1.snap new file mode 100644 index 00000000..201881d8 --- /dev/null +++ b/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_1.snap @@ -0,0 +1,10 @@ +--- +source: src/config/auth/mod.rs +expression: auth +--- +[jwt] +secret = 'foo' + +[jwt.claims] +audience = [] +required-claims = [] diff --git a/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_2.snap b/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_2.snap new file mode 100644 index 00000000..437eb969 --- /dev/null +++ b/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_2.snap @@ -0,0 +1,10 @@ +--- +source: src/config/auth/mod.rs +expression: auth +--- +[jwt] +secret = 'foo' + +[jwt.claims] +audience = ['bar'] +required-claims = [] diff --git a/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_3.snap b/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_3.snap new file mode 100644 index 00000000..de5e49ac --- /dev/null +++ b/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_3.snap @@ -0,0 +1,10 @@ +--- +source: src/config/auth/mod.rs +expression: auth +--- +[jwt] +secret = 'foo' + +[jwt.claims] +audience = [] +required-claims = ['baz'] diff --git a/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_4.snap b/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_4.snap new file mode 100644 index 00000000..73e9fc85 --- /dev/null +++ b/src/config/auth/snapshots/roadster__config__auth__tests__auth@case_4.snap @@ -0,0 +1,10 @@ +--- +source: src/config/auth/mod.rs +expression: auth +--- +[jwt] +secret = 'foo' + +[jwt.claims] +audience = ['bar'] +required-claims = ['baz'] diff --git a/src/config/database.rs b/src/config/database.rs new file mode 100644 index 00000000..b70cc45e --- /dev/null +++ b/src/config/database.rs @@ -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")] + pub idle_timeout: Option, + #[serde_as(as = "Option")] + pub max_lifetime: Option, + #[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); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 64270d13..6f5fb431 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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; diff --git a/src/config/service/http/default_routes.rs b/src/config/service/http/default_routes.rs new file mode 100644 index 00000000..802b364e --- /dev/null +++ b/src/config/service/http/default_routes.rs @@ -0,0 +1,339 @@ +use crate::app_context::AppContext; +use crate::util::serde_util; +use crate::util::serde_util::default_true; +use serde_derive::{Deserialize, Serialize}; +use validator::Validate; +use validator::ValidationError; + +#[derive(Debug, Clone, Validate, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[validate(schema(function = "validate_default_routes"))] +pub struct DefaultRoutes { + #[serde(default = "default_true")] + pub default_enable: bool, + + #[serde(deserialize_with = "deserialize_ping", default = "default_ping")] + pub ping: DefaultRouteConfig, + + #[serde(deserialize_with = "deserialize_health", default = "default_health")] + pub health: DefaultRouteConfig, + + #[cfg(feature = "open-api")] + #[serde( + deserialize_with = "deserialize_api_schema", + default = "default_api_schema" + )] + pub api_schema: DefaultRouteConfig, + + #[cfg(feature = "open-api")] + #[serde(deserialize_with = "deserialize_scalar", default = "default_scalar")] + pub scalar: DefaultRouteConfig, + + #[cfg(feature = "open-api")] + #[serde(deserialize_with = "deserialize_redoc", default = "default_redoc")] + pub redoc: DefaultRouteConfig, +} + +impl Default for DefaultRoutes { + fn default() -> Self { + Self { + default_enable: default_true(), + ping: default_ping(), + health: default_health(), + #[cfg(feature = "open-api")] + api_schema: default_api_schema(), + #[cfg(feature = "open-api")] + scalar: default_scalar(), + #[cfg(feature = "open-api")] + redoc: default_redoc(), + } + } +} + +fn validate_default_routes( + // This parameter isn't used for some feature flag combinations + #[allow(unused)] default_routes: &DefaultRoutes, +) -> Result<(), ValidationError> { + #[cfg(feature = "open-api")] + { + let default_enable = default_routes.default_enable; + let api_schema_enabled = default_routes.api_schema.enable.unwrap_or(default_enable); + let scalar_enabled = default_routes.scalar.enable.unwrap_or(default_enable); + let redoc_enabled = default_routes.redoc.enable.unwrap_or(default_enable); + + if scalar_enabled && !api_schema_enabled { + return Err(ValidationError::new( + "The Open API schema route must be enabled in order to use the Scalar docs route.", + )); + } + if redoc_enabled && !api_schema_enabled { + return Err(ValidationError::new( + "The Open API schema route must be enabled in order to use the Redoc docs route.", + )); + } + } + + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct DefaultRouteConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable: Option, + pub route: String, +} + +impl DefaultRouteConfig { + pub fn enabled(&self, context: &AppContext) -> bool { + self.enable.unwrap_or( + context + .config() + .service + .http + .custom + .default_routes + .default_enable, + ) + } +} + +// This fun boilerplate allows the user to +// 1. Partially override a config without needing to provide all of the required values for the config +// 2. Prevent a type's `Default` implementation from being used and overriding the default we +// actually want. For example, we provide a default for the `route` fields, and we want that +// value to be used if the user doesn't provide one, not the type's default (`""` in this case). +// +// See: https://users.rust-lang.org/t/serde-default-value-for-struct-field-depending-on-parent/73452/2 +// +// This is mainly needed because all of the default routes share a struct for their common configs, +// so we can't simply set a default on the field directly with a serde annotation. +// An alternative implementation could be to have different structs for each default route's common +// config instead of sharing a struct type. However, that would still require a lot of boilerplate. + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "kebab-case", default)] +struct PartialDefaultRouteConfig { + pub enable: Option, + pub route: Option, +} + +fn deserialize_ping<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(map_empty_config("_ping".to_string())) +} + +fn default_ping() -> DefaultRouteConfig { + deserialize_ping(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_health<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(map_empty_config("_health".to_string())) +} + +fn default_health() -> DefaultRouteConfig { + deserialize_health(serde_util::empty_json_object()).unwrap() +} + +#[cfg(feature = "open-api")] +fn deserialize_api_schema<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config("_docs/api.json".to_string())) +} + +#[cfg(feature = "open-api")] +fn default_api_schema() -> DefaultRouteConfig { + deserialize_api_schema(serde_util::empty_json_object()).unwrap() +} + +#[cfg(feature = "open-api")] +fn deserialize_scalar<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(map_empty_config("_docs".to_string())) +} + +#[cfg(feature = "open-api")] +fn default_scalar() -> DefaultRouteConfig { + deserialize_scalar(serde_util::empty_json_object()).unwrap() +} + +#[cfg(feature = "open-api")] +fn deserialize_redoc<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(map_empty_config("_docs/redoc".to_string())) +} + +#[cfg(feature = "open-api")] +fn default_redoc() -> DefaultRouteConfig { + deserialize_redoc(serde_util::empty_json_object()).unwrap() +} + +fn map_empty_config( + default_route: String, +) -> impl FnOnce(PartialDefaultRouteConfig) -> DefaultRouteConfig { + move |PartialDefaultRouteConfig { enable, route }| DefaultRouteConfig { + enable, + route: route.unwrap_or(default_route), + } +} + +#[cfg(test)] +mod tests { + use crate::config::service::http::*; + use rstest::rstest; + + #[rstest] + #[case(false, false)] + #[case(true, false)] + #[cfg(not(feature = "open-api"))] + #[cfg_attr(coverage_nightly, coverage(off))] + fn validate_default_routes(#[case] default_enable: bool, #[case] validation_error: bool) { + // Arrange + #[allow(clippy::field_reassign_with_default)] + let config = { + let mut config = DefaultRoutes::default(); + config.default_enable = default_enable; + config + }; + + // Act + let result = config.validate(); + + // Assert + assert_eq!(result.is_err(), validation_error); + } + + #[rstest] + #[case(false, None, None, None, false)] + #[case(true, None, None, None, false)] + #[case(false, None, Some(true), None, true)] + #[case(false, None, None, Some(true), true)] + #[case(false, Some(true), Some(true), None, false)] + #[case(false, Some(true), None, Some(true), false)] + #[cfg(feature = "open-api")] + #[cfg_attr(coverage_nightly, coverage(off))] + fn validate_default_routes( + #[case] default_enable: bool, + #[case] api_schema_enabled: Option, + #[case] scalar_enabled: Option, + #[case] redoc_enabled: Option, + #[case] validation_error: bool, + ) { + // Arrange + #[allow(clippy::field_reassign_with_default)] + let config = { + let mut config = DefaultRoutes::default(); + config.default_enable = default_enable; + config.api_schema.enable = api_schema_enabled; + config.scalar.enable = scalar_enabled; + config.redoc.enable = redoc_enabled; + config + }; + + // Act + let result = config.validate(); + + // Assert + assert_eq!(result.is_err(), validation_error); + } +} + +// To simplify testing, these are only run when all of the config fields are available +#[cfg(all(test, feature = "open-api"))] +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("")] + #[case( + r#" + default-enable = false + [ping] + enable = true + [health] + enable = true + [api-schema] + enable = true + [scalar] + enable = true + [redoc] + enable = true + "# + )] + #[case( + r#" + default-enable = false + [ping] + enable = false + [health] + enable = false + [api-schema] + enable = false + [scalar] + enable = false + [redoc] + enable = false + "# + )] + #[case( + r#" + default-enable = false + [ping] + route = "a" + [health] + route = "b" + [api-schema] + route = "c" + [scalar] + route = "d" + [redoc] + route = "e" + "# + )] + #[case( + r#" + [ping] + enable = true + route = "a" + [health] + enable = true + route = "b" + [api-schema] + enable = true + route = "c" + [scalar] + enable = true + route = "d" + [redoc] + enable = true + route = "e" + "# + )] + #[cfg_attr(coverage_nightly, coverage(off))] + fn auth(_case: TestCase, #[case] config: &str) { + let default_routes: DefaultRoutes = toml::from_str(config).unwrap(); + + assert_toml_snapshot!(default_routes); + } +} diff --git a/src/config/service/http/initializer.rs b/src/config/service/http/initializer.rs index a900cecb..b80a8902 100644 --- a/src/config/service/http/initializer.rs +++ b/src/config/service/http/initializer.rs @@ -1,6 +1,8 @@ use crate::app_context::AppContext; use crate::config::app_config::CustomConfig; use crate::service::http::initializer::normalize_path::NormalizePathConfig; +use crate::util::serde_util; +use crate::util::serde_util::default_true; use serde_derive::{Deserialize, Serialize}; use std::collections::BTreeMap; use validator::Validate; @@ -11,7 +13,13 @@ pub const PRIORITY_LAST: i32 = 10_000; #[derive(Debug, Clone, Validate, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default)] pub struct Initializer { + #[serde(default = "default_true")] pub default_enable: bool, + + #[serde( + deserialize_with = "deserialize_normalize_path", + default = "default_normalize_path" + )] pub normalize_path: InitializerConfig, /// Allows providing configs for custom initializers. Any configs that aren't pre-defined above /// will be collected here. @@ -47,34 +55,27 @@ pub struct Initializer { impl Default for Initializer { fn default() -> Self { - let normalize_path: InitializerConfig = Default::default(); - let normalize_path = normalize_path.set_priority(PRIORITY_LAST); - Self { - default_enable: true, - normalize_path, + default_enable: default_true(), + normalize_path: default_normalize_path(), custom: Default::default(), } } } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] pub struct CommonConfig { // Optional so we can tell the difference between a consumer explicitly enabling/disabling // the initializer, vs the initializer being enabled/disabled by default. // If this is `None`, the value will match the value of `Initializer#default_enable`. #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] pub enable: Option, pub priority: i32, } impl CommonConfig { - pub fn set_priority(mut self, priority: i32) -> Self { - self.priority = priority; - self - } - pub fn enabled(&self, context: &AppContext) -> bool { self.enable.unwrap_or( context @@ -88,49 +89,133 @@ impl CommonConfig { } } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default)] -pub struct InitializerConfig { +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct InitializerConfig { #[serde(flatten)] pub common: CommonConfig, #[serde(flatten)] pub custom: T, } -impl InitializerConfig { - pub fn set_priority(mut self, priority: i32) -> Self { - self.common = self.common.set_priority(priority); - self +// This fun boilerplate allows the user to +// 1. Partially override a config without needing to provide all of the required values for the config +// 2. Prevent a type's `Default` implementation from being used and overriding the default we +// actually want. For example, we provide a default for the `priority` fields, and we want that +// value to be used if the user doesn't provide one, not the type's default (`0` in this case). +// +// See: https://users.rust-lang.org/t/serde-default-value-for-struct-field-depending-on-parent/73452/2 +// +// This is mainly needed because all of the initializers share a struct for their common configs, +// so we can't simply set a default on the field directly with a serde annotation. +// An alternative implementation could be to have different structs for each initializer's common +// config instead of sharing a struct type. However, that would still require a lot of boilerplate. + +struct Priorities { + normalize_path: i32, +} + +impl Default for Priorities { + fn default() -> Self { + let normalize_path = PRIORITY_LAST; + + Self { normalize_path } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "kebab-case", default)] +pub struct PartialCommonConfig { + pub enable: Option, + pub priority: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct IncompleteInitializerConfig { + #[serde(flatten)] + pub common: PartialCommonConfig, + #[serde(flatten)] + pub custom: T, +} + +fn deserialize_normalize_path<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config(Priorities::default().normalize_path)) +} + +fn default_normalize_path() -> InitializerConfig { + deserialize_normalize_path(serde_util::empty_json_object()).unwrap() +} + +fn map_empty_config( + default_priority: i32, +) -> impl FnOnce(IncompleteInitializerConfig) -> InitializerConfig { + move |IncompleteInitializerConfig { common, custom }| InitializerConfig { + common: CommonConfig { + enable: common.enable, + priority: common.priority.unwrap_or(default_priority), + }, + custom, } } #[cfg(test)] mod tests { use super::*; - use serde_json::Value; + use crate::util::test_util::TestCase; + use insta::assert_toml_snapshot; + use rstest::{fixture, rstest}; - #[test] + #[fixture] #[cfg_attr(coverage_nightly, coverage(off))] - fn custom_config() { - // Note: since we're parsing into a Initializer config struct directly, we don't - // need to prefix `foo` with `initializer`. If we want to actually provide custom initializer - // configs, the table key will need to be `[initializer.foo]`. - let config = r#" + fn case() -> TestCase { + Default::default() + } + + #[rstest] + #[case("")] + #[case( + r#" + [normalize-path] + enable = false + "# + )] + #[case( + r#" + [normalize-path] + priority = 1234 + "# + )] + #[case( + r#" + default-enable = false + "# + )] + #[case( + r#" + default-enable = false + [normalize-path] + enable = false + priority = 1234 + "# + )] + #[case( + r#" [foo] enable = true priority = 10 x = "y" - "#; - let config: Initializer = toml::from_str(config).unwrap(); - - assert!(config.custom.contains_key("foo")); - - let config = config.custom.get("foo").unwrap(); - assert_eq!(config.common.enable, Some(true)); - assert_eq!(config.common.priority, 10); + "# + )] + #[cfg_attr(coverage_nightly, coverage(off))] + fn initializer(_case: TestCase, #[case] config: &str) { + let initializer: Initializer = toml::from_str(config).unwrap(); - assert!(config.custom.config.contains_key("x")); - let x = config.custom.config.get("x").unwrap(); - assert_eq!(x, &Value::String("y".to_string())); + assert_toml_snapshot!(initializer); } } diff --git a/src/config/service/http/middleware.rs b/src/config/service/http/middleware.rs index d721e127..3ab5fc39 100644 --- a/src/config/service/http/middleware.rs +++ b/src/config/service/http/middleware.rs @@ -11,6 +11,8 @@ use crate::service::http::middleware::sensitive_headers::{ use crate::service::http::middleware::size_limit::SizeLimitConfig; use crate::service::http::middleware::timeout::TimeoutConfig; use crate::service::http::middleware::tracing::TracingConfig; +use crate::util::serde_util; +use crate::util::serde_util::default_true; use serde_derive::{Deserialize, Serialize}; use std::collections::BTreeMap; use validator::Validate; @@ -21,16 +23,61 @@ pub const PRIORITY_LAST: i32 = 10_000; #[derive(Debug, Clone, Validate, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default)] pub struct Middleware { + #[serde(default = "default_true")] pub default_enable: bool, + + #[serde( + deserialize_with = "deserialize_sensitive_request_headers", + default = "default_sensitive_request_headers" + )] pub sensitive_request_headers: MiddlewareConfig, + + #[serde( + deserialize_with = "deserialize_sensitive_response_headers", + default = "default_sensitive_response_headers" + )] pub sensitive_response_headers: MiddlewareConfig, + + #[serde( + deserialize_with = "deserialize_set_request_id", + default = "default_set_request_id" + )] pub set_request_id: MiddlewareConfig, + + #[serde( + deserialize_with = "deserialize_propagate_request_id", + default = "default_propagate_request_id" + )] pub propagate_request_id: MiddlewareConfig, + + #[serde(deserialize_with = "deserialize_tracing", default = "default_tracing")] pub tracing: MiddlewareConfig, + + #[serde( + deserialize_with = "deserialize_catch_panic", + default = "default_catch_panic" + )] pub catch_panic: MiddlewareConfig, + + #[serde( + deserialize_with = "deserialize_response_compression", + default = "default_response_compression" + )] pub response_compression: MiddlewareConfig, + + #[serde( + deserialize_with = "deserialize_request_decompression", + default = "default_request_decompression" + )] pub request_decompression: MiddlewareConfig, + + #[serde(deserialize_with = "deserialize_timeout", default = "default_timeout")] pub timeout: MiddlewareConfig, + + #[serde( + deserialize_with = "deserialize_size_limit", + default = "default_size_limit" + )] pub size_limit: MiddlewareConfig, /// Allows providing configs for custom middleware. Any configs that aren't pre-defined above /// will be collected here. @@ -66,104 +113,296 @@ pub struct Middleware { impl Default for Middleware { fn default() -> Self { - // Before request middlewares + Self { + default_enable: default_true(), + sensitive_request_headers: default_sensitive_request_headers(), + sensitive_response_headers: default_sensitive_response_headers(), + set_request_id: default_set_request_id(), + propagate_request_id: default_propagate_request_id(), + tracing: default_tracing(), + catch_panic: default_catch_panic(), + response_compression: default_response_compression(), + request_decompression: default_request_decompression(), + timeout: default_timeout(), + size_limit: default_size_limit(), + custom: Default::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct CommonConfig { + // Optional so we can tell the difference between a consumer explicitly enabling/disabling + // the middleware, vs the middleware being enabled/disabled by default. + // If this is `None`, the value will match the value of `Middleware#default_enable`. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub enable: Option, + pub priority: i32, +} + +impl CommonConfig { + pub fn enabled(&self, context: &AppContext) -> bool { + self.enable.unwrap_or( + context + .config() + .service + .http + .custom + .middleware + .default_enable, + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct MiddlewareConfig { + #[serde(flatten)] + pub common: CommonConfig, + #[serde(flatten)] + pub custom: T, +} + +// This fun boilerplate allows the user to +// 1. Partially override a config without needing to provide all of the required values for the config +// 2. Prevent a type's `Default` implementation from being used and overriding the default we +// actually want. For example, we provide a default for the `priority` fields, and we want that +// value to be used if the user doesn't provide one, not the type's default (`0` in this case). +// +// See: https://users.rust-lang.org/t/serde-default-value-for-struct-field-depending-on-parent/73452/2 +// +// This is mainly needed because all of the middleware share a struct for their common configs, +// so we can't simply set a default on the field directly with a serde annotation. +// An alternative implementation could be to have different structs for each middleware's common +// config instead of sharing a struct type. However, that would still require a lot of boilerplate. + +struct Priorities { + sensitive_request_headers: i32, + sensitive_response_headers: i32, + set_request_id: i32, + propagate_request_id: i32, + tracing: i32, + catch_panic: i32, + response_compression: i32, + request_decompression: i32, + timeout: i32, + size_limit: i32, +} + +impl Default for Priorities { + fn default() -> Self { let mut priority = PRIORITY_FIRST; - let sensitive_request_headers: MiddlewareConfig = - Default::default(); - let sensitive_request_headers = sensitive_request_headers.set_priority(priority); + let sensitive_request_headers = priority; priority += 10; - let set_request_id: MiddlewareConfig = Default::default(); - let set_request_id = set_request_id.set_priority(priority); + let set_request_id = priority; priority += 10; - let tracing: MiddlewareConfig = Default::default(); - let tracing = tracing.set_priority(priority); + let tracing = priority; priority += 10; - let size_limit: MiddlewareConfig = Default::default(); - let size_limit = size_limit.set_priority(priority); + let size_limit = priority; priority += 10; - let request_decompression: MiddlewareConfig = - Default::default(); - let request_decompression = request_decompression.set_priority(priority); + let request_decompression = priority; // Somewhere in the middle, order doesn't particularly matter - let catch_panic: MiddlewareConfig = Default::default(); - let response_compression: MiddlewareConfig = Default::default(); - let timeout: MiddlewareConfig = Default::default(); + let catch_panic = 0; + let response_compression = 0; + let timeout = 0; // Before response middlewares let mut priority = PRIORITY_LAST; - let sensitive_response_headers: MiddlewareConfig = - Default::default(); - let sensitive_response_headers = sensitive_response_headers.set_priority(priority); + let sensitive_response_headers = priority; priority -= 10; - let propagate_request_id: MiddlewareConfig = Default::default(); - let propagate_request_id = propagate_request_id.set_priority(priority); + let propagate_request_id = priority; Self { - default_enable: true, sensitive_request_headers, - sensitive_response_headers, set_request_id, - propagate_request_id, tracing, + size_limit, + request_decompression, catch_panic, response_compression, - request_decompression, timeout, - size_limit, - custom: Default::default(), + sensitive_response_headers, + propagate_request_id, } } } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "kebab-case", default)] -pub struct CommonConfig { - // Optional so we can tell the difference between a consumer explicitly enabling/disabling - // the middleware, vs the middleware being enabled/disabled by default. - // If this is `None`, the value will match the value of `Middleware#default_enable`. - #[serde(skip_serializing_if = "Option::is_none")] +pub struct PartialCommonConfig { pub enable: Option, - pub priority: i32, -} - -impl CommonConfig { - pub fn set_priority(mut self, priority: i32) -> Self { - self.priority = priority; - self - } - - pub fn enabled(&self, context: &AppContext) -> bool { - self.enable.unwrap_or( - context - .config() - .service - .http - .custom - .middleware - .default_enable, - ) - } + pub priority: Option, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default)] -pub struct MiddlewareConfig { +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct IncompleteMiddlewareConfig { #[serde(flatten)] - pub common: CommonConfig, + pub common: PartialCommonConfig, #[serde(flatten)] pub custom: T, } -impl MiddlewareConfig { - pub fn set_priority(mut self, priority: i32) -> Self { - self.common = self.common.set_priority(priority); - self +fn deserialize_sensitive_request_headers<'de, D, T>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(map_empty_config( + Priorities::default().sensitive_request_headers, + )) +} + +fn default_sensitive_request_headers() -> MiddlewareConfig { + deserialize_sensitive_request_headers(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_sensitive_response_headers<'de, D, T>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(map_empty_config( + Priorities::default().sensitive_response_headers, + )) +} + +fn default_sensitive_response_headers() -> MiddlewareConfig { + deserialize_sensitive_response_headers(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_set_request_id<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config(Priorities::default().set_request_id)) +} + +fn default_set_request_id() -> MiddlewareConfig { + deserialize_set_request_id(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_propagate_request_id<'de, D, T>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config(Priorities::default().propagate_request_id)) +} + +fn default_propagate_request_id() -> MiddlewareConfig { + deserialize_propagate_request_id(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_tracing<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config(Priorities::default().tracing)) +} + +fn default_tracing() -> MiddlewareConfig { + deserialize_tracing(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_catch_panic<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config(Priorities::default().catch_panic)) +} + +fn default_catch_panic() -> MiddlewareConfig { + deserialize_catch_panic(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_response_compression<'de, D, T>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config(Priorities::default().response_compression)) +} + +fn default_response_compression() -> MiddlewareConfig { + deserialize_response_compression(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_request_decompression<'de, D, T>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(map_empty_config( + Priorities::default().request_decompression, + )) +} + +fn default_request_decompression() -> MiddlewareConfig { + deserialize_request_decompression(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_timeout<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config(Priorities::default().timeout)) +} + +fn default_timeout() -> MiddlewareConfig { + deserialize_timeout(serde_util::empty_json_object()).unwrap() +} + +fn deserialize_size_limit<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) + .map(map_empty_config(Priorities::default().size_limit)) +} + +fn default_size_limit() -> MiddlewareConfig { + deserialize_size_limit(serde_util::empty_json_object()).unwrap() +} + +fn map_empty_config( + default_priority: i32, +) -> impl FnOnce(IncompleteMiddlewareConfig) -> MiddlewareConfig { + move |IncompleteMiddlewareConfig { common, custom }| MiddlewareConfig { + common: CommonConfig { + enable: common.enable, + priority: common.priority.unwrap_or(default_priority), + }, + custom, } } @@ -172,7 +411,6 @@ mod tests { use super::*; use crate::config::app_config::AppConfig; use rstest::rstest; - use serde_json::Value; #[rstest] #[case(true, None, true)] @@ -195,35 +433,90 @@ mod tests { let common_config = CommonConfig { enable, - ..Default::default() + priority: 0, }; // Act/Assert assert_eq!(common_config.enabled(&context), expected_enabled); } +} + +#[cfg(test)] +mod deserialize_tests { + use super::*; + use crate::util::test_util::TestCase; + use insta::assert_toml_snapshot; + use rstest::{fixture, rstest}; - #[test] + #[fixture] #[cfg_attr(coverage_nightly, coverage(off))] - fn custom_config() { - // Note: since we're parsing into a Middleware config struct directly, we don't - // need to prefix `foo` with `middleware`. If we want to actually provide custom middleware - // configs, the table key will need to be `[middleware.foo]`. - let config = r#" + fn case() -> TestCase { + Default::default() + } + + #[rstest] + #[case("")] + #[case( + r#" + [sensitive-request-headers] + enable = false + [sensitive-response-headers] + enable = false + [set-request-id] + enable = false + [propagate-request-id] + enable = false + [tracing] + enable = false + [catch-panic] + enable = false + [response-compression] + enable = false + [request-decompression] + enable = false + [timeout] + enable = false + [size-limit] + enable = false + "# + )] + #[case( + r#" + default-enable = false + [sensitive-request-headers] + priority = -1 + [sensitive-response-headers] + priority = 0 + [set-request-id] + priority = 1 + [propagate-request-id] + priority = 2 + [tracing] + priority = 3 + [catch-panic] + priority = 4 + [response-compression] + priority = 5 + [request-decompression] + priority = 6 + [timeout] + priority = 7 + [size-limit] + priority = 8 + "# + )] + #[case( + r#" [foo] enable = true priority = 10 x = "y" - "#; - let config: Middleware = toml::from_str(config).unwrap(); - - assert!(config.custom.contains_key("foo")); - - let config = config.custom.get("foo").unwrap(); - assert_eq!(config.common.enable, Some(true)); - assert_eq!(config.common.priority, 10); + "# + )] + #[cfg_attr(coverage_nightly, coverage(off))] + fn middleware(_case: TestCase, #[case] config: &str) { + let middleware: Middleware = toml::from_str(config).unwrap(); - assert!(config.custom.config.contains_key("x")); - let x = config.custom.config.get("x").unwrap(); - assert_eq!(x, &Value::String("y".to_string())); + assert_toml_snapshot!(middleware); } } diff --git a/src/config/service/http/mod.rs b/src/config/service/http/mod.rs index 4fc52f42..ca6b2868 100644 --- a/src/config/service/http/mod.rs +++ b/src/config/service/http/mod.rs @@ -1,10 +1,10 @@ -use crate::app_context::AppContext; use crate::config::service::http::initializer::Initializer; use crate::config::service::http::middleware::Middleware; -use crate::util::serde_util::default_true; +use default_routes::DefaultRoutes; use serde_derive::{Deserialize, Serialize}; -use validator::{Validate, ValidationError}; +use validator::Validate; +pub mod default_routes; pub mod initializer; pub mod middleware; @@ -37,132 +37,3 @@ impl Address { format!("{}:{}", self.host, self.port) } } - -#[derive(Debug, Clone, Default, Validate, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -#[validate(schema(function = "validate_default_routes"))] -pub struct DefaultRoutes { - #[serde(default = "default_true")] - pub default_enable: bool, - #[serde(default)] - pub ping: DefaultRouteConfig, - #[serde(default)] - pub health: DefaultRouteConfig, - #[cfg(feature = "open-api")] - #[serde(default)] - pub api_schema: DefaultRouteConfig, - #[cfg(feature = "open-api")] - #[serde(default)] - pub scalar: DefaultRouteConfig, - #[cfg(feature = "open-api")] - #[serde(default)] - pub redoc: DefaultRouteConfig, -} - -fn validate_default_routes( - // This parameter isn't used for some feature flag combinations - #[allow(unused)] default_routes: &DefaultRoutes, -) -> Result<(), ValidationError> { - #[cfg(feature = "open-api")] - { - let default_enable = default_routes.default_enable; - let api_schema_enabled = default_routes.api_schema.enable.unwrap_or(default_enable); - let scalar_enabled = default_routes.scalar.enable.unwrap_or(default_enable); - let redoc_enabled = default_routes.redoc.enable.unwrap_or(default_enable); - - if scalar_enabled && !api_schema_enabled { - return Err(ValidationError::new( - "The Open API schema route must be enabled in order to use the Scalar docs route.", - )); - } - if redoc_enabled && !api_schema_enabled { - return Err(ValidationError::new( - "The Open API schema route must be enabled in order to use the Redoc docs route.", - )); - } - } - - Ok(()) -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct DefaultRouteConfig { - pub enable: Option, - pub route: Option, -} - -impl DefaultRouteConfig { - pub fn enabled(&self, context: &AppContext) -> bool { - self.enable.unwrap_or( - context - .config() - .service - .http - .custom - .default_routes - .default_enable, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - #[case(false, false)] - #[case(true, false)] - #[cfg(not(feature = "open-api"))] - #[cfg_attr(coverage_nightly, coverage(off))] - fn validate_default_routes(#[case] default_enable: bool, #[case] validation_error: bool) { - // Arrange - #[allow(clippy::field_reassign_with_default)] - let config = { - let mut config = DefaultRoutes::default(); - config.default_enable = default_enable; - config - }; - - // Act - let result = config.validate(); - - // Assert - assert_eq!(result.is_err(), validation_error); - } - - #[rstest] - #[case(false, None, None, None, false)] - #[case(true, None, None, None, false)] - #[case(false, None, Some(true), None, true)] - #[case(false, None, None, Some(true), true)] - #[case(false, Some(true), Some(true), None, false)] - #[case(false, Some(true), None, Some(true), false)] - #[cfg(feature = "open-api")] - #[cfg_attr(coverage_nightly, coverage(off))] - fn validate_default_routes( - #[case] default_enable: bool, - #[case] api_schema_enabled: Option, - #[case] scalar_enabled: Option, - #[case] redoc_enabled: Option, - #[case] validation_error: bool, - ) { - // Arrange - #[allow(clippy::field_reassign_with_default)] - let config = { - let mut config = DefaultRoutes::default(); - config.default_enable = default_enable; - config.api_schema.enable = api_schema_enabled; - config.scalar.enable = scalar_enabled; - config.redoc.enable = redoc_enabled; - config - }; - - // Act - let result = config.validate(); - - // Assert - assert_eq!(result.is_err(), validation_error); - } -} diff --git a/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_1.snap b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_1.snap new file mode 100644 index 00000000..7b8e4157 --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_1.snap @@ -0,0 +1,20 @@ +--- +source: src/config/service/http/default_routes.rs +expression: default_routes +--- +default-enable = true + +[ping] +route = '_ping' + +[health] +route = '_health' + +[api-schema] +route = '_docs/api.json' + +[scalar] +route = '_docs' + +[redoc] +route = '_docs/redoc' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_2.snap b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_2.snap new file mode 100644 index 00000000..4265adfc --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_2.snap @@ -0,0 +1,25 @@ +--- +source: src/config/service/http/default_routes.rs +expression: default_routes +--- +default-enable = false + +[ping] +enable = true +route = '_ping' + +[health] +enable = true +route = '_health' + +[api-schema] +enable = true +route = '_docs/api.json' + +[scalar] +enable = true +route = '_docs' + +[redoc] +enable = true +route = '_docs/redoc' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_3.snap b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_3.snap new file mode 100644 index 00000000..c6d0083f --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_3.snap @@ -0,0 +1,25 @@ +--- +source: src/config/service/http/default_routes.rs +expression: default_routes +--- +default-enable = false + +[ping] +enable = false +route = '_ping' + +[health] +enable = false +route = '_health' + +[api-schema] +enable = false +route = '_docs/api.json' + +[scalar] +enable = false +route = '_docs' + +[redoc] +enable = false +route = '_docs/redoc' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_4.snap b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_4.snap new file mode 100644 index 00000000..8d88cb13 --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_4.snap @@ -0,0 +1,20 @@ +--- +source: src/config/service/http/default_routes.rs +expression: default_routes +--- +default-enable = false + +[ping] +route = 'a' + +[health] +route = 'b' + +[api-schema] +route = 'c' + +[scalar] +route = 'd' + +[redoc] +route = 'e' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_5.snap b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_5.snap new file mode 100644 index 00000000..595df9aa --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__default_routes__deserialize_tests__auth@case_5.snap @@ -0,0 +1,25 @@ +--- +source: src/config/service/http/default_routes.rs +expression: default_routes +--- +default-enable = true + +[ping] +enable = true +route = 'a' + +[health] +enable = true +route = 'b' + +[api-schema] +enable = true +route = 'c' + +[scalar] +enable = true +route = 'd' + +[redoc] +enable = true +route = 'e' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_1.snap b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_1.snap new file mode 100644 index 00000000..2701257c --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_1.snap @@ -0,0 +1,8 @@ +--- +source: src/config/service/http/initializer.rs +expression: initializer +--- +default-enable = true + +[normalize-path] +priority = 10000 diff --git a/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_2.snap b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_2.snap new file mode 100644 index 00000000..442d945a --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_2.snap @@ -0,0 +1,9 @@ +--- +source: src/config/service/http/initializer.rs +expression: initializer +--- +default-enable = true + +[normalize-path] +enable = false +priority = 10000 diff --git a/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_3.snap b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_3.snap new file mode 100644 index 00000000..17e36f3d --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_3.snap @@ -0,0 +1,8 @@ +--- +source: src/config/service/http/initializer.rs +expression: initializer +--- +default-enable = true + +[normalize-path] +priority = 1234 diff --git a/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_4.snap b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_4.snap new file mode 100644 index 00000000..f05e563a --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_4.snap @@ -0,0 +1,8 @@ +--- +source: src/config/service/http/initializer.rs +expression: initializer +--- +default-enable = false + +[normalize-path] +priority = 10000 diff --git a/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_5.snap b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_5.snap new file mode 100644 index 00000000..4a46b338 --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_5.snap @@ -0,0 +1,9 @@ +--- +source: src/config/service/http/initializer.rs +expression: initializer +--- +default-enable = false + +[normalize-path] +enable = false +priority = 1234 diff --git a/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_6.snap b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_6.snap new file mode 100644 index 00000000..d46bcc89 --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__initializer__tests__initializer@case_6.snap @@ -0,0 +1,13 @@ +--- +source: src/config/service/http/initializer.rs +expression: initializer +--- +default-enable = true + +[normalize-path] +priority = 10000 + +[foo] +enable = true +priority = 10 +x = 'y' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_1.snap b/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_1.snap new file mode 100644 index 00000000..8f4fe3d2 --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_1.snap @@ -0,0 +1,51 @@ +--- +source: src/config/service/http/middleware.rs +expression: middleware +--- +default-enable = true + +[sensitive-request-headers] +priority = -10000 +header-names = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', +] + +[sensitive-response-headers] +priority = 10000 +header-names = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', +] + +[set-request-id] +priority = -9990 +header-name = 'request-id' + +[propagate-request-id] +priority = 9990 +header-name = 'request-id' + +[tracing] +priority = -9980 + +[catch-panic] +priority = 0 + +[response-compression] +priority = 0 + +[request-decompression] +priority = -9960 + +[timeout] +priority = 0 +timeout = 10000 + +[size-limit] +priority = -9970 +limit = '5 MB' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_2.snap b/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_2.snap new file mode 100644 index 00000000..0c3ddeaa --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_2.snap @@ -0,0 +1,61 @@ +--- +source: src/config/service/http/middleware.rs +expression: middleware +--- +default-enable = true + +[sensitive-request-headers] +enable = false +priority = -10000 +header-names = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', +] + +[sensitive-response-headers] +enable = false +priority = 10000 +header-names = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', +] + +[set-request-id] +enable = false +priority = -9990 +header-name = 'request-id' + +[propagate-request-id] +enable = false +priority = 9990 +header-name = 'request-id' + +[tracing] +enable = false +priority = -9980 + +[catch-panic] +enable = false +priority = 0 + +[response-compression] +enable = false +priority = 0 + +[request-decompression] +enable = false +priority = -9960 + +[timeout] +enable = false +priority = 0 +timeout = 10000 + +[size-limit] +enable = false +priority = -9970 +limit = '5 MB' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_3.snap b/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_3.snap new file mode 100644 index 00000000..d79a9f0a --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_3.snap @@ -0,0 +1,51 @@ +--- +source: src/config/service/http/middleware.rs +expression: middleware +--- +default-enable = false + +[sensitive-request-headers] +priority = -1 +header-names = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', +] + +[sensitive-response-headers] +priority = 0 +header-names = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', +] + +[set-request-id] +priority = 1 +header-name = 'request-id' + +[propagate-request-id] +priority = 2 +header-name = 'request-id' + +[tracing] +priority = 3 + +[catch-panic] +priority = 4 + +[response-compression] +priority = 5 + +[request-decompression] +priority = 6 + +[timeout] +priority = 7 +timeout = 10000 + +[size-limit] +priority = 8 +limit = '5 MB' diff --git a/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_4.snap b/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_4.snap new file mode 100644 index 00000000..6b51c696 --- /dev/null +++ b/src/config/service/http/snapshots/roadster__config__service__http__middleware__deserialize_tests__middleware@case_4.snap @@ -0,0 +1,56 @@ +--- +source: src/config/service/http/middleware.rs +expression: middleware +--- +default-enable = true + +[sensitive-request-headers] +priority = -10000 +header-names = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', +] + +[sensitive-response-headers] +priority = 10000 +header-names = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', +] + +[set-request-id] +priority = -9990 +header-name = 'request-id' + +[propagate-request-id] +priority = 9990 +header-name = 'request-id' + +[tracing] +priority = -9980 + +[catch-panic] +priority = 0 + +[response-compression] +priority = 0 + +[request-decompression] +priority = -9960 + +[timeout] +priority = 0 +timeout = 10000 + +[size-limit] +priority = -9970 +limit = '5 MB' + +[foo] +enable = true +priority = 10 +x = 'y' diff --git a/src/config/service/worker/sidekiq/mod.rs b/src/config/service/worker/sidekiq/mod.rs index 9f212126..48bdaa2b 100644 --- a/src/config/service/worker/sidekiq/mod.rs +++ b/src/config/service/worker/sidekiq/mod.rs @@ -7,9 +7,6 @@ use validator::Validate; #[derive(Debug, Clone, Validate, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct SidekiqServiceConfig { - #[validate(nested)] - pub redis: Redis, - /// The number of Sidekiq workers that can run at the same time. Adjust as needed based on /// your workload and resource (cpu/memory/etc) usage. /// @@ -26,15 +23,18 @@ pub struct SidekiqServiceConfig { #[serde(default)] pub queues: Vec, + #[validate(nested)] + pub redis: Redis, + #[serde(default)] #[validate(nested)] pub periodic: Periodic, /// The default app worker config. Values can be overridden on a per-worker basis by /// implementing the corresponding [crate::service::worker::sidekiq::app_worker::AppWorker] methods. - #[serde(default, flatten)] + #[serde(default)] #[validate(nested)] - pub worker_config: AppWorkerConfig, + pub app_worker: AppWorkerConfig, } impl SidekiqServiceConfig { @@ -92,3 +92,80 @@ pub struct ConnectionPool { pub min_idle: Option, pub max_connections: Option, } + +#[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#" + # The default `num-workers` is the same as the number of cpu cores, so we always set + # this in our tests so they always pass regardless of the host's hardware. + num-workers = 1 + [redis] + uri = "redis://localhost:6379" + "# + )] + #[case( + r#" + num-workers = 1 + [redis] + uri = "redis://localhost:6379" + "# + )] + #[case( + r#" + num-workers = 1 + queues = ["foo"] + [redis] + uri = "redis://localhost:6379" + "# + )] + #[case( + r#" + num-workers = 1 + [redis] + uri = "redis://localhost:6379" + [redis.enqueue-pool] + min-idle = 1 + [redis.fetch-pool] + min-idle = 2 + "# + )] + #[case( + r#" + num-workers = 1 + [redis] + uri = "redis://localhost:6379" + [redis.enqueue-pool] + max-connections = 1 + [redis.fetch-pool] + max-connections = 2 + "# + )] + #[case( + r#" + num-workers = 1 + [redis] + uri = "redis://localhost:6379" + [periodic] + stale-cleanup = "auto-clean-stale" + "# + )] + #[cfg_attr(coverage_nightly, coverage(off))] + fn sidekiq(_case: TestCase, #[case] config: &str) { + let sidekiq: SidekiqServiceConfig = toml::from_str(config).unwrap(); + + assert_toml_snapshot!(sidekiq); + } +} diff --git a/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_1.snap b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_1.snap new file mode 100644 index 00000000..3968afce --- /dev/null +++ b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_1.snap @@ -0,0 +1,22 @@ +--- +source: src/config/service/worker/sidekiq/mod.rs +expression: sidekiq +--- +num-workers = 1 +queues = [] + +[redis] +uri = 'redis://localhost:6379' + +[redis.enqueue-pool] + +[redis.fetch-pool] + +[periodic] +stale-cleanup = 'auto-clean-stale' + +[app-worker] +max-retries = 5 +timeout = true +max-duration = 60 +disable-argument-coercion = false diff --git a/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_2.snap b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_2.snap new file mode 100644 index 00000000..3968afce --- /dev/null +++ b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_2.snap @@ -0,0 +1,22 @@ +--- +source: src/config/service/worker/sidekiq/mod.rs +expression: sidekiq +--- +num-workers = 1 +queues = [] + +[redis] +uri = 'redis://localhost:6379' + +[redis.enqueue-pool] + +[redis.fetch-pool] + +[periodic] +stale-cleanup = 'auto-clean-stale' + +[app-worker] +max-retries = 5 +timeout = true +max-duration = 60 +disable-argument-coercion = false diff --git a/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_3.snap b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_3.snap new file mode 100644 index 00000000..28e2a72f --- /dev/null +++ b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_3.snap @@ -0,0 +1,22 @@ +--- +source: src/config/service/worker/sidekiq/mod.rs +expression: sidekiq +--- +num-workers = 8 +queues = ['foo'] + +[redis] +uri = 'redis://localhost:6379' + +[redis.enqueue-pool] + +[redis.fetch-pool] + +[periodic] +stale-cleanup = 'auto-clean-stale' + +[app-worker] +max-retries = 5 +timeout = true +max-duration = 60 +disable-argument-coercion = false diff --git a/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_4.snap b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_4.snap new file mode 100644 index 00000000..61ee6006 --- /dev/null +++ b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_4.snap @@ -0,0 +1,24 @@ +--- +source: src/config/service/worker/sidekiq/mod.rs +expression: sidekiq +--- +num-workers = 1 +queues = [] + +[redis] +uri = 'redis://localhost:6379' + +[redis.enqueue-pool] +min-idle = 1 + +[redis.fetch-pool] +min-idle = 2 + +[periodic] +stale-cleanup = 'auto-clean-stale' + +[app-worker] +max-retries = 5 +timeout = true +max-duration = 60 +disable-argument-coercion = false diff --git a/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_5.snap b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_5.snap new file mode 100644 index 00000000..0b01cc52 --- /dev/null +++ b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_5.snap @@ -0,0 +1,24 @@ +--- +source: src/config/service/worker/sidekiq/mod.rs +expression: sidekiq +--- +num-workers = 1 +queues = [] + +[redis] +uri = 'redis://localhost:6379' + +[redis.enqueue-pool] +max-connections = 1 + +[redis.fetch-pool] +max-connections = 2 + +[periodic] +stale-cleanup = 'auto-clean-stale' + +[app-worker] +max-retries = 5 +timeout = true +max-duration = 60 +disable-argument-coercion = false diff --git a/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_6.snap b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_6.snap new file mode 100644 index 00000000..3968afce --- /dev/null +++ b/src/config/service/worker/sidekiq/snapshots/roadster__config__service__worker__sidekiq__deserialize_tests__sidekiq@case_6.snap @@ -0,0 +1,22 @@ +--- +source: src/config/service/worker/sidekiq/mod.rs +expression: sidekiq +--- +num-workers = 1 +queues = [] + +[redis] +uri = 'redis://localhost:6379' + +[redis.enqueue-pool] + +[redis.fetch-pool] + +[periodic] +stale-cleanup = 'auto-clean-stale' + +[app-worker] +max-retries = 5 +timeout = true +max-duration = 60 +disable-argument-coercion = false diff --git a/src/config/snapshots/roadster__config__database__deserialize_tests__sidekiq@case_1.snap b/src/config/snapshots/roadster__config__database__deserialize_tests__sidekiq@case_1.snap new file mode 100644 index 00000000..2f281ec7 --- /dev/null +++ b/src/config/snapshots/roadster__config__database__deserialize_tests__sidekiq@case_1.snap @@ -0,0 +1,10 @@ +--- +source: src/config/database.rs +expression: database +--- +uri = 'https://example.com:1234/' +auto-migrate = true +connect-timeout = 1000 +acquire-timeout = 1000 +min-connections = 0 +max-connections = 1 diff --git a/src/config/snapshots/roadster__config__database__deserialize_tests__sidekiq@case_2.snap b/src/config/snapshots/roadster__config__database__deserialize_tests__sidekiq@case_2.snap new file mode 100644 index 00000000..4b05a989 --- /dev/null +++ b/src/config/snapshots/roadster__config__database__deserialize_tests__sidekiq@case_2.snap @@ -0,0 +1,12 @@ +--- +source: src/config/database.rs +expression: database +--- +uri = 'https://example.com:1234/' +auto-migrate = true +connect-timeout = 1000 +acquire-timeout = 2000 +idle-timeout = 3000 +max-lifetime = 4000 +min-connections = 0 +max-connections = 1 diff --git a/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_1.snap b/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_1.snap new file mode 100644 index 00000000..8e6e78ef --- /dev/null +++ b/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_1.snap @@ -0,0 +1,6 @@ +--- +source: src/config/tracing.rs +expression: tracing +--- +level = 'debug' +trace-propagation = true diff --git a/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_2.snap b/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_2.snap new file mode 100644 index 00000000..b32f8333 --- /dev/null +++ b/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_2.snap @@ -0,0 +1,7 @@ +--- +source: src/config/tracing.rs +expression: tracing +--- +level = 'info' +service-name = 'foo' +trace-propagation = true diff --git a/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_3.snap b/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_3.snap new file mode 100644 index 00000000..4f39fd2c --- /dev/null +++ b/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_3.snap @@ -0,0 +1,6 @@ +--- +source: src/config/tracing.rs +expression: tracing +--- +level = 'error' +trace-propagation = false diff --git a/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_4.snap b/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_4.snap new file mode 100644 index 00000000..eb4791bd --- /dev/null +++ b/src/config/snapshots/roadster__config__tracing__deserialize_tests__sidekiq@case_4.snap @@ -0,0 +1,7 @@ +--- +source: src/config/tracing.rs +expression: tracing +--- +level = 'debug' +trace-propagation = true +otlp-endpoint = 'https://example.com:1234/' diff --git a/src/config/tracing.rs b/src/config/tracing.rs new file mode 100644 index 00000000..27d80e9e --- /dev/null +++ b/src/config/tracing.rs @@ -0,0 +1,72 @@ +#[cfg(feature = "otel")] +use crate::util::serde_util::default_true; +use serde_derive::{Deserialize, Serialize}; +#[cfg(feature = "otel")] +use url::Url; +use validator::Validate; + +#[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`][crate::config::app_config::App] config value, translated to `snake_case`. + #[cfg(feature = "otel")] + pub service_name: Option, + + /// 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, +} + +// To simplify testing, these are only run when all of the config fields are available +#[cfg(all(test, feature = "otel"))] +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#" + level = "debug" + "# + )] + #[case( + r#" + level = "info" + service-name = "foo" + "# + )] + #[case( + r#" + level = "error" + trace-propagation = false + "# + )] + #[case( + r#" + level = "debug" + otlp-endpoint = "https://example.com:1234" + "# + )] + #[cfg_attr(coverage_nightly, coverage(off))] + fn sidekiq(_case: TestCase, #[case] config: &str) { + let tracing: Tracing = toml::from_str(config).unwrap(); + + assert_toml_snapshot!(tracing); + } +} diff --git a/src/controller/http/docs.rs b/src/controller/http/docs.rs index de03a400..9f620bdb 100644 --- a/src/controller/http/docs.rs +++ b/src/controller/http/docs.rs @@ -1,5 +1,4 @@ use crate::app_context::AppContext; -use crate::config::app_config::AppConfig; use crate::controller::http::build_path; use aide::axum::routing::get_with; use aide::axum::{ApiRouter, IntoApiResponse}; @@ -20,7 +19,7 @@ where S: Clone + Send + Sync + 'static, { let parent = build_path(parent, BASE); - let open_api_schema_path = build_path(&parent, &api_schema_route(context)); + let open_api_schema_path = build_path(&parent, api_schema_route(context)); let router = ApiRouter::new(); if !api_schema_enabled(context) { @@ -34,7 +33,7 @@ where let router = if scalar_enabled(context) { router.api_route_with( - &build_path(&parent, &scalar_route(context)), + &build_path(&parent, scalar_route(context)), get_with( Scalar::new(&open_api_schema_path) .with_title(&context.config().app.name) @@ -49,7 +48,7 @@ where let router = if redoc_enabled(context) { router.api_route_with( - &build_path(&parent, &redoc_route(context)), + &build_path(&parent, redoc_route(context)), get_with( Redoc::new(&open_api_schema_path) .with_title(&context.config().app.name) @@ -80,17 +79,15 @@ fn scalar_enabled(context: &AppContext) -> bool { .enabled(context) } -fn scalar_route(context: &AppContext) -> String { - let config: &AppConfig = context.config(); - config +fn scalar_route(context: &AppContext) -> &str { + &context + .config() .service .http .custom .default_routes .scalar .route - .clone() - .unwrap_or_else(|| "/".to_string()) } fn redoc_enabled(context: &AppContext) -> bool { @@ -104,17 +101,15 @@ fn redoc_enabled(context: &AppContext) -> bool { .enabled(context) } -fn redoc_route(context: &AppContext) -> String { - let config: &AppConfig = context.config(); - config +fn redoc_route(context: &AppContext) -> &str { + &context + .config() .service .http .custom .default_routes .redoc .route - .clone() - .unwrap_or_else(|| "redoc".to_string()) } fn api_schema_enabled(context: &AppContext) -> bool { @@ -128,17 +123,15 @@ fn api_schema_enabled(context: &AppContext) -> bool { .enabled(context) } -fn api_schema_route(context: &AppContext) -> String { - let config: &AppConfig = context.config(); - config +fn api_schema_route(context: &AppContext) -> &str { + &context + .config() .service .http .custom .default_routes .api_schema .route - .clone() - .unwrap_or_else(|| "api.json".to_string()) } #[cfg(test)] @@ -164,20 +157,22 @@ mod tests { let mut config = AppConfig::test(None).unwrap(); config.service.http.custom.default_routes.default_enable = default_enable; config.service.http.custom.default_routes.scalar.enable = enable; - config - .service - .http - .custom - .default_routes - .scalar - .route - .clone_from(&route); + if let Some(route) = route.as_ref() { + config + .service + .http + .custom + .default_routes + .scalar + .route + .clone_from(route); + } let context = AppContext::<()>::test(Some(config), None).unwrap(); assert_eq!(scalar_enabled(&context), enabled); assert_eq!( scalar_route(&context), - route.unwrap_or_else(|| "/".to_string()) + route.unwrap_or_else(|| "_docs".to_string()) ); } @@ -196,20 +191,22 @@ mod tests { let mut config = AppConfig::test(None).unwrap(); config.service.http.custom.default_routes.default_enable = default_enable; config.service.http.custom.default_routes.redoc.enable = enable; - config - .service - .http - .custom - .default_routes - .redoc - .route - .clone_from(&route); + if let Some(route) = route.as_ref() { + config + .service + .http + .custom + .default_routes + .redoc + .route + .clone_from(route); + } let context = AppContext::<()>::test(Some(config), None).unwrap(); assert_eq!(redoc_enabled(&context), enabled); assert_eq!( redoc_route(&context), - route.unwrap_or_else(|| "redoc".to_string()) + route.unwrap_or_else(|| "_docs/redoc".to_string()) ); } @@ -228,20 +225,22 @@ mod tests { let mut config = AppConfig::test(None).unwrap(); config.service.http.custom.default_routes.default_enable = default_enable; config.service.http.custom.default_routes.api_schema.enable = enable; - config - .service - .http - .custom - .default_routes - .api_schema - .route - .clone_from(&route); + if let Some(route) = route.as_ref() { + config + .service + .http + .custom + .default_routes + .api_schema + .route + .clone_from(route); + } let context = AppContext::<()>::test(Some(config), None).unwrap(); assert_eq!(api_schema_enabled(&context), enabled); assert_eq!( api_schema_route(&context), - route.unwrap_or_else(|| "api.json".to_string()) + route.unwrap_or_else(|| "_docs/api.json".to_string()) ); } } diff --git a/src/controller/http/health.rs b/src/controller/http/health.rs index 9d497aa6..fb59f037 100644 --- a/src/controller/http/health.rs +++ b/src/controller/http/health.rs @@ -1,5 +1,4 @@ use crate::app_context::AppContext; -use crate::config::app_config::AppConfig; use crate::controller::http::build_path; use crate::view::http::app_error::AppError; #[cfg(feature = "open-api")] @@ -40,7 +39,7 @@ where if !enabled(context) { return router; } - let root = build_path(parent, &route(context)); + let root = build_path(parent, route(context)); router.route(&root, get(health_get::)) } @@ -53,7 +52,7 @@ where if !enabled(context) { return router; } - let root = build_path(parent, &route(context)); + let root = build_path(parent, route(context)); router.api_route(&root, get_with(health_get::, health_get_docs)) } @@ -68,17 +67,15 @@ fn enabled(context: &AppContext) -> bool { .enabled(context) } -fn route(context: &AppContext) -> String { - let config: &AppConfig = context.config(); - config +fn route(context: &AppContext) -> &str { + &context + .config() .service .http .custom .default_routes .health .route - .clone() - .unwrap_or_else(|| "_health".to_string()) } #[serde_as] @@ -269,14 +266,16 @@ mod tests { let mut config = AppConfig::test(None).unwrap(); config.service.http.custom.default_routes.default_enable = default_enable; config.service.http.custom.default_routes.health.enable = enable; - config - .service - .http - .custom - .default_routes - .health - .route - .clone_from(&route); + if let Some(route) = route.as_ref() { + config + .service + .http + .custom + .default_routes + .health + .route + .clone_from(route); + } let context = AppContext::<()>::test(Some(config), None).unwrap(); assert_eq!(super::enabled(&context), enabled); diff --git a/src/controller/http/ping.rs b/src/controller/http/ping.rs index dc0b64fa..5677e82f 100644 --- a/src/controller/http/ping.rs +++ b/src/controller/http/ping.rs @@ -1,5 +1,4 @@ use crate::app_context::AppContext; -use crate::config::app_config::AppConfig; use crate::controller::http::build_path; use crate::view::http::app_error::AppError; #[cfg(feature = "open-api")] @@ -27,7 +26,7 @@ where if !enabled(context) { return router; } - let root = build_path(parent, &route(context)); + let root = build_path(parent, route(context)); router.route(&root, get(ping_get)) } @@ -40,7 +39,7 @@ where if !enabled(context) { return router; } - let root = build_path(parent, &route(context)); + let root = build_path(parent, route(context)); router.api_route(&root, get_with(ping_get, ping_get_docs)) } @@ -55,17 +54,15 @@ fn enabled(context: &AppContext) -> bool { .enabled(context) } -fn route(context: &AppContext) -> String { - let config: &AppConfig = context.config(); - config +fn route(context: &AppContext) -> &str { + &context + .config() .service .http .custom .default_routes .ping .route - .clone() - .unwrap_or_else(|| "_ping".to_string()) } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -108,14 +105,16 @@ mod tests { let mut config = AppConfig::test(None).unwrap(); config.service.http.custom.default_routes.default_enable = default_enable; config.service.http.custom.default_routes.ping.enable = enable; - config - .service - .http - .custom - .default_routes - .ping - .route - .clone_from(&route); + if let Some(route) = route.as_ref() { + config + .service + .http + .custom + .default_routes + .ping + .route + .clone_from(route); + } let context = AppContext::<()>::test(Some(config), None).unwrap(); assert_eq!(super::enabled(&context), enabled); diff --git a/src/service/worker/sidekiq/app_worker.rs b/src/service/worker/sidekiq/app_worker.rs index 471b3db3..e082c8ba 100644 --- a/src/service/worker/sidekiq/app_worker.rs +++ b/src/service/worker/sidekiq/app_worker.rs @@ -83,7 +83,7 @@ where .service .sidekiq .custom - .worker_config + .app_worker .max_retries } @@ -91,13 +91,7 @@ where /// /// The default implementation uses the value from the app's config file. fn timeout(&self, context: &AppContext) -> bool { - context - .config() - .service - .sidekiq - .custom - .worker_config - .timeout + context.config().service.sidekiq.custom.app_worker.timeout } /// See [AppWorkerConfig::max_duration]. @@ -109,7 +103,7 @@ where .service .sidekiq .custom - .worker_config + .app_worker .max_duration } @@ -122,7 +116,7 @@ where .service .sidekiq .custom - .worker_config + .app_worker .disable_argument_coercion } } @@ -176,3 +170,46 @@ mod tests { assert!(value.inner.disable_argument_coercion); } } + +#[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("")] + #[case( + r#" + max-retries = 1 + "# + )] + #[case( + r#" + timeout = false + "# + )] + #[case( + r#" + max-duration = 1234 + "# + )] + #[case( + r#" + disable-argument-coercion = true + "# + )] + #[cfg_attr(coverage_nightly, coverage(off))] + fn app_worker(_case: TestCase, #[case] config: &str) { + let app_worker: AppWorkerConfig = toml::from_str(config).unwrap(); + + assert_toml_snapshot!(app_worker); + } +} diff --git a/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_1.snap b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_1.snap new file mode 100644 index 00000000..dbe764fd --- /dev/null +++ b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_1.snap @@ -0,0 +1,8 @@ +--- +source: src/service/worker/sidekiq/app_worker.rs +expression: app_worker +--- +max-retries = 5 +timeout = true +max-duration = 60 +disable-argument-coercion = false diff --git a/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_2.snap b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_2.snap new file mode 100644 index 00000000..a6025783 --- /dev/null +++ b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_2.snap @@ -0,0 +1,8 @@ +--- +source: src/service/worker/sidekiq/app_worker.rs +expression: app_worker +--- +max-retries = 1 +timeout = true +max-duration = 60 +disable-argument-coercion = false diff --git a/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_3.snap b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_3.snap new file mode 100644 index 00000000..92f6087f --- /dev/null +++ b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_3.snap @@ -0,0 +1,8 @@ +--- +source: src/service/worker/sidekiq/app_worker.rs +expression: app_worker +--- +max-retries = 5 +timeout = false +max-duration = 60 +disable-argument-coercion = false diff --git a/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_4.snap b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_4.snap new file mode 100644 index 00000000..c0d66167 --- /dev/null +++ b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_4.snap @@ -0,0 +1,8 @@ +--- +source: src/service/worker/sidekiq/app_worker.rs +expression: app_worker +--- +max-retries = 5 +timeout = true +max-duration = 1234 +disable-argument-coercion = false diff --git a/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_5.snap b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_5.snap new file mode 100644 index 00000000..3ccd8cc9 --- /dev/null +++ b/src/service/worker/sidekiq/snapshots/roadster__service__worker__sidekiq__app_worker__deserialize_tests__app_worker@case_5.snap @@ -0,0 +1,8 @@ +--- +source: src/service/worker/sidekiq/app_worker.rs +expression: app_worker +--- +max-retries = 5 +timeout = true +max-duration = 60 +disable-argument-coercion = true diff --git a/src/util/serde_util.rs b/src/util/serde_util.rs index e9e18e60..cef0bf08 100644 --- a/src/util/serde_util.rs +++ b/src/util/serde_util.rs @@ -1,8 +1,10 @@ use std::fmt::Display; use std::str::FromStr; +use serde::de::IntoDeserializer; use serde::{de, Deserializer, Serializer}; use serde_derive::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use url::Url; /// Custom deserializer to allow deserializing a string field as the given type `T`, as long as @@ -50,10 +52,16 @@ impl Display for UriOrString { } /// Function to default a boolean field to `true`. -pub fn default_true() -> bool { +pub const fn default_true() -> bool { true } +// This method isn't used for some feature combinations +#[allow(dead_code)] +pub(crate) fn empty_json_object() -> impl for<'de> Deserializer<'de> { + Value::Object(Map::new()).into_deserializer() +} + #[cfg(test)] mod tests { use super::*;