use anyhow::{Context, Result, anyhow};
use secrecy::{ExposeSecret, SecretString};
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode};
pub fn get_configuration<S: for<'a> serde::Deserialize<'a>>() -> Result<Settings<S>> {
let base_path =
std::env::current_dir().with_context(|| "Failed to determine the current directory")?;
let configuration_directory = base_path.join("configuration");
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.with_context(|| "Failed to parse APP_ENVIRONMENT")?;
let environment_filename = format!("{}.yaml", environment.as_str());
let settings = config::Config::builder()
.add_source(config::File::from(configuration_directory.join("base.yaml")))
.add_source(
config::File::from(configuration_directory.join(environment_filename)).required(false)
)
.add_source(config::Environment::with_prefix("APP").prefix_separator("_").separator("__"))
.build()
.with_context(|| "failed to build configurations")?;
let settings = settings
.try_deserialize::<Settings<S>>()
.with_context(|| "failed to deserialize configuration")?;
if matches!(environment, Environment::Production | Environment::Staging) {
validate_production_config(&settings)?;
}
Ok(settings)
}
pub fn validate_production_config<S>(settings: &Settings<S>) -> Result<()> {
let checks: Vec<(&str, &str, &str)> = vec![
("graphdb.password", settings.graphdb.password.expose_secret(), "testpassword"),
("graphdb.host", &settings.graphdb.host, "localhost"),
("database.password", settings.database.password.expose_secret(), "secret"),
("database.host", &settings.database.host, "localhost"),
("database.host", &settings.database.host, "0.0.0.0"),
];
let mut errors: Vec<String> = Vec::new();
for (field, actual, forbidden) in &checks {
if *actual == *forbidden {
errors.push(format!(
"[production] configuration field `{field}` must be explicitly set \
— fallback defaults are not permitted in production"
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(anyhow!("Production configuration validation failed:\n{}", errors.join("\n")))
}
}
#[derive(serde::Deserialize, Clone)]
pub struct Settings<T> {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
pub redis_uri: SecretString,
pub custom: T,
#[serde(default)]
pub database_admin: Option<DatabaseAdminSettings>,
pub graphdb: GraphDatabaseSettings
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct DatabaseAdminSettings {
#[serde(default = "default_admin_migrations_path")]
pub admin_migrations_path: String,
#[serde(default = "default_app_migrations_path")]
pub app_migrations_path: String,
#[serde(default = "default_superuser")]
pub superuser: String,
#[serde(default = "default_superuser_pwd")]
pub superuser_pwd: String,
#[serde(default = "default_migrator_user")]
pub migrator_user: String,
#[serde(default = "default_migrator_pwd")]
pub migrator_pwd: String,
#[serde(default = "default_app_user")]
pub app_user: String,
#[serde(default = "default_app_user_pwd")]
pub app_user_pwd: String,
#[serde(default = "default_table_provisioner_user")]
pub table_provisioner_user: String,
#[serde(default = "default_table_provisioner_pwd")]
pub table_provisioner_pwd: String,
#[serde(default = "default_search_path")]
pub search_path: String
}
fn default_admin_migrations_path() -> String {
"admin_migrations".into()
}
fn default_app_migrations_path() -> String {
"migrations".into()
}
fn default_superuser() -> String {
"postgres".into()
}
fn default_superuser_pwd() -> String {
"password".into()
}
fn default_migrator_user() -> String {
"db_migrator".into()
}
fn default_migrator_pwd() -> String {
"migrator_secret".into()
}
fn default_app_user() -> String {
"app".into()
}
fn default_app_user_pwd() -> String {
"secret".into()
}
fn default_table_provisioner_user() -> String {
"table_provisioner".into()
}
fn default_table_provisioner_pwd() -> String {
"provisioner_secret".into()
}
fn default_search_path() -> String {
"options=-csearch_path%3Dapp".into()
}
#[derive(serde::Deserialize, Clone)]
pub struct DatabaseSettings {
pub username: String,
pub password: SecretString,
pub host: String,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub database_name: String,
pub require_ssl: bool
}
impl DatabaseSettings {
pub fn connect_options(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl { PgSslMode::Require } else { PgSslMode::Prefer };
PgConnectOptions::new()
.host(&self.host)
.port(self.port)
.password(self.password.expose_secret())
.username(&self.username)
.ssl_mode(ssl_mode)
.database(&self.database_name)
}
pub fn get_connection_pool(&self) -> PgPool {
PgPoolOptions::new().connect_lazy_with(self.connect_options())
}
}
#[derive(serde::Deserialize, Clone)]
pub struct GraphDatabaseSettings {
pub host: String,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub username: String,
pub password: SecretString
}
impl GraphDatabaseSettings {
pub async fn connect(&self) -> anyhow::Result<neo4rs::Graph> {
let bolt_url = format!("bolt://{}:{}", self.host, self.port);
neo4rs::Graph::new(&bolt_url, self.username.clone(), self.password.expose_secret())
.await
.with_context(|| format!("failed to connect to Neo4j at {}", bolt_url))
}
pub fn from_config() -> anyhow::Result<Self> {
let base_path = std::env::current_dir().context("failed to determine current directory")?;
let config_dir = base_path.join("configuration");
let env_name = std::env::var("APP_ENVIRONMENT").unwrap_or_else(|_| "test".into());
let raw = config::Config::builder()
.add_source(config::File::from(config_dir.join("base.yaml")))
.add_source(config::File::from(config_dir.join(format!("{env_name}.yaml"))))
.build()
.context("failed to build configuration")?;
raw.get::<Self>("graphdb").context("failed to deserialise graphdb configuration")
}
}
#[derive(serde::Deserialize, Clone)]
pub struct ApplicationSettings {
pub host: String,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub base_url: String,
pub hmac_secret: SecretString,
pub cors_allowed_origin: String
}
pub enum Environment {
Local,
Dev,
Test,
Staging,
Production
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Local => "local",
Environment::Dev => "dev",
Environment::Test => "test",
Environment::Staging => "staging",
Environment::Production => "production"
}
}
}
impl TryFrom<String> for Environment {
type Error = anyhow::Error;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"dev" => Ok(Self::Dev),
"test" => Ok(Self::Test),
"staging" => Ok(Self::Staging),
"production" => Ok(Self::Production),
other => Err(anyhow!("{} is not a supported environment", other))
}
}
}