Skip to content

Commit

Permalink
Add validation of the AppConfig
Browse files Browse the repository at this point in the history
Most things don't need validation, so they just have the Validate derive
and `#[validate(nested)]` applied to get the validations initially
chained through the app config. However, we did implement a validation
for the `DefaultRoutes` config.
  • Loading branch information
spencewenski committed May 19, 2024
1 parent 50b13a9 commit e3a2795
Show file tree
Hide file tree
Showing 12 changed files with 130 additions and 44 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ num-traits = "0.2.19"
log = "0.4.21"
mockall_double = "0.3.1"
futures = "0.3.30"
validator = { version = "0.18.1", features = ["derive"] }

[dev-dependencies]
cargo-husky = { version = "1.5.0", default-features = false, features = ["user-hooks"] }
Expand Down
16 changes: 16 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use std::future::Future;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, instrument, warn};
use validator::Validate;

// todo: this method is getting unweildy, we should break it up
pub async fn start<A>(
Expand Down Expand Up @@ -84,6 +85,11 @@ where

A::init_tracing(&config)?;

#[cfg(not(feature = "cli"))]
validate_config(&config, true)?;
#[cfg(feature = "cli")]
validate_config(&config, !roadster_cli.skip_validate_config)?;

#[cfg(all(not(test), feature = "db-sql"))]
let db = Database::connect(A::db_connection_options(&config)?).await?;

Expand Down Expand Up @@ -242,6 +248,16 @@ where
Ok(())
}

fn validate_config(config: &AppConfig, exit_on_error: bool) -> anyhow::Result<()> {
let result = config.validate();
if exit_on_error {
result?;
} else if let Err(err) = result {
warn!("An error occurred when validating the app config: {}", err);
}
Ok(())
}

#[async_trait]
pub trait App: Send + Sync {
// Todo: Are clone, etc necessary if we store it inside an Arc?
Expand Down
5 changes: 5 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ pub struct RoadsterCli {
#[clap(short, long)]
pub environment: Option<Environment>,

/// Skip validation of the app config. This can be useful for debugging the app config
/// when used in conjunction with the `print-config` command.
#[clap(long, action)]
pub skip_validate_config: bool,

/// Allow dangerous/destructive operations when running in the `production` environment. If
/// this argument is not provided, dangerous/destructive operations will not be performed
/// when running in `production`.
Expand Down
10 changes: 5 additions & 5 deletions src/cli/print_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,19 @@ where
) -> anyhow::Result<bool> {
match self.format {
Format::Debug => {
info!("{:?}", context.config())
info!("\n{:?}", context.config())
}
Format::Json => {
info!("{}", serde_json::to_string(&context.config())?)
info!("\n{}", serde_json::to_string(&context.config())?)
}
Format::JsonPretty => {
info!("{}", serde_json::to_string_pretty(&context.config())?)
info!("\n{}", serde_json::to_string_pretty(&context.config())?)
}
Format::Toml => {
info!("{}", toml::to_string(&context.config())?)
info!("\n{}", toml::to_string(&context.config())?)
}
Format::TomlPretty => {
info!("{}", toml::to_string_pretty(&context.config())?)
info!("\n{}", toml::to_string_pretty(&context.config())?)
}
}

Expand Down
22 changes: 15 additions & 7 deletions src/config/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@ use std::collections::BTreeMap;
use std::time::Duration;
#[cfg(any(feature = "otel", feature = "db-sql"))]
use url::Url;
use validator::Validate;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct AppConfig {
#[validate(nested)]
pub app: App,
#[validate(nested)]
pub service: Service,
#[validate(nested)]
pub auth: Auth,
#[validate(nested)]
pub tracing: Tracing,
pub environment: Environment,
#[cfg(feature = "db-sql")]
#[validate(nested)]
pub database: Database,
/// Allows providing custom config values. Any configs that aren't pre-defined above
/// will be collected here.
Expand Down Expand Up @@ -119,7 +125,7 @@ impl AppConfig {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct App {
pub name: String,
Expand All @@ -128,21 +134,23 @@ pub struct App {
pub shutdown_on_error: bool,
}

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

#[derive(Debug, Clone, Serialize, Deserialize)]
#[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, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct JwtClaims {
// Todo: Default to the server URL?
Expand All @@ -151,7 +159,7 @@ pub struct JwtClaims {
pub required_claims: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Tracing {
pub level: String,
Expand All @@ -173,7 +181,7 @@ pub struct Tracing {

#[cfg(feature = "db-sql")]
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[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`
Expand Down
3 changes: 2 additions & 1 deletion src/config/service/http/initializer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ use crate::config::app_config::CustomConfig;
use crate::service::http::initializer::normalize_path::NormalizePathConfig;
use serde_derive::{Deserialize, Serialize};
use std::collections::BTreeMap;
use validator::Validate;

pub const PRIORITY_FIRST: i32 = -10_000;
pub const PRIORITY_LAST: i32 = 10_000;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct Initializer {
pub default_enable: bool,
Expand Down
3 changes: 2 additions & 1 deletion src/config/service/http/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ use crate::service::http::middleware::timeout::TimeoutConfig;
use crate::service::http::middleware::tracing::TracingConfig;
use serde_derive::{Deserialize, Serialize};
use std::collections::BTreeMap;
use validator::Validate;

pub const PRIORITY_FIRST: i32 = -10_000;
pub const PRIORITY_LAST: i32 = 10_000;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct Middleware {
pub default_enable: bool,
Expand Down
74 changes: 65 additions & 9 deletions src/config/service/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,31 @@ use crate::app_context::AppContext;
use crate::config::service::http::initializer::Initializer;
use crate::config::service::http::middleware::Middleware;
use crate::controller::http::build_path;
use crate::util::serde_util::default_true;
use serde_derive::{Deserialize, Serialize};
use validator::{Validate, ValidationError};

pub mod initializer;
pub mod middleware;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct HttpServiceConfig {
#[serde(flatten)]
#[validate(nested)]
pub address: Address,
#[serde(default)]
#[validate(nested)]
pub middleware: Middleware,
#[serde(default)]
#[validate(nested)]
pub initializer: Initializer,
#[serde(default)]
#[validate(nested)]
pub default_routes: DefaultRoutes,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Address {
pub host: String,
Expand All @@ -34,36 +40,63 @@ impl Address {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[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(default = "DefaultRouteConfig::default_ping")]
pub ping: DefaultRouteConfig,
#[serde(default = "DefaultRouteConfig::default_health")]
pub health: DefaultRouteConfig,
#[cfg(feature = "open-api")]
#[serde(default = "DefaultRouteConfig::default_api_schema")]
pub api_schema: DefaultRouteConfig,
#[cfg(feature = "open-api")]
#[serde(default = "DefaultRouteConfig::default_scalar")]
pub scalar: DefaultRouteConfig,
#[cfg(feature = "open-api")]
#[serde(default = "DefaultRouteConfig::default_redoc")]
pub redoc: DefaultRouteConfig,
}

impl Default for DefaultRoutes {
fn default() -> Self {
Self {
default_enable: true,
ping: DefaultRouteConfig::new("_ping"),
health: DefaultRouteConfig::new("_health"),
default_enable: default_true(),
ping: DefaultRouteConfig::default_ping(),
health: DefaultRouteConfig::default_health(),
#[cfg(feature = "open-api")]
api_schema: DefaultRouteConfig::new("_docs/api.json"),
api_schema: DefaultRouteConfig::default_api_schema(),
#[cfg(feature = "open-api")]
scalar: DefaultRouteConfig::new("_docs"),
scalar: DefaultRouteConfig::default_scalar(),
#[cfg(feature = "open-api")]
redoc: DefaultRouteConfig::new("_docs/redoc"),
redoc: DefaultRouteConfig::default_redoc(),
}
}
}

fn validate_default_routes(default_routes: &DefaultRoutes) -> Result<(), ValidationError> {
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 {
Expand All @@ -79,6 +112,29 @@ impl DefaultRouteConfig {
}
}

fn default_ping() -> Self {
DefaultRouteConfig::new("_ping")
}

fn default_health() -> Self {
DefaultRouteConfig::new("_health")
}

#[cfg(feature = "open-api")]
fn default_api_schema() -> Self {
DefaultRouteConfig::new("_docs/api.json")
}

#[cfg(feature = "open-api")]
fn default_scalar() -> Self {
DefaultRouteConfig::new("_docs")
}

#[cfg(feature = "open-api")]
fn default_redoc() -> Self {
DefaultRouteConfig::new("_docs/redoc")
}

pub fn enabled<S>(&self, context: &AppContext<S>) -> bool {
self.enable.unwrap_or(
context
Expand Down
10 changes: 7 additions & 3 deletions src/config/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ use crate::config::service::http::HttpServiceConfig;
use crate::config::service::worker::sidekiq::SidekiqServiceConfig;
use crate::util::serde_util::default_true;
use serde_derive::{Deserialize, Serialize};
use validator::Validate;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Service {
#[serde(default = "default_true")]
pub default_enable: bool,
#[cfg(feature = "http")]
#[validate(nested)]
pub http: ServiceConfig<HttpServiceConfig>,
#[cfg(feature = "sidekiq")]
#[validate(nested)]
pub sidekiq: ServiceConfig<SidekiqServiceConfig>,
}

Expand All @@ -39,11 +42,12 @@ impl CommonConfig {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ServiceConfig<T> {
pub struct ServiceConfig<T: Validate> {
#[serde(flatten, default)]
pub common: CommonConfig,
#[serde(flatten)]
#[validate(nested)]
pub custom: T,
}
Loading

0 comments on commit e3a2795

Please sign in to comment.