scratchstack-config 0.1.2

Scratchstack configuration schema and logic
Documentation
use {
    crate::error::{ConfigError, DatabaseConfigErrorKind},
    log::{debug, error, info},
    serde::Deserialize,
    sqlx::{any::Any as AnyDB, pool::PoolOptions},
    std::{fmt::Debug, fs::read, time::Duration},
};

#[derive(Clone, Deserialize, Debug)]
pub struct DatabaseConfig {
    pub url: String,

    #[serde(default)]
    pub password: Option<String>,

    #[serde(default)]
    pub password_file: Option<String>,

    #[serde(default)]
    pub max_connections: Option<u32>,

    #[serde(default)]
    pub min_connections: Option<u32>,

    #[serde(with = "humantime_serde", default)]
    pub connection_timeout: Option<Duration>,

    #[serde(with = "humantime_serde", default)]
    pub max_lifetime: Option<Duration>,

    #[serde(with = "humantime_serde", default)]
    pub idle_timeout: Option<Duration>,

    #[serde(default)]
    pub test_before_acquire: Option<bool>,
}

impl DatabaseConfig {
    pub fn get_database_url(&self) -> Result<String, ConfigError> {
        let url = self.url.clone();

        if let Some(password) = &self.password {
            debug!("Database password specified in config file -- replacing occurrences in URL");
            Ok(url.replace("${password}", password))
        } else if let Some(password_file) = &self.password_file {
            debug!("Database password file specified.");
            match read(password_file) {
                Ok(password_u8) => match std::str::from_utf8(&password_u8) {
                    Ok(password) => {
                        info!("Successfully read database password file {}; replacing URL", password_file);
                        let password = password.trim();
                        Ok(url.replace("${password}", password))
                    }
                    Err(e) => {
                        error!("Found non-UTF-8 characters in database password file {}", password_file);
                        Err(DatabaseConfigErrorKind::InvalidPasswordFileEncoding(password_file.to_string(), e).into())
                    }
                },
                Err(e) => {
                    error!("Failed to open database password file {}: {}", password_file, e);
                    Err(ConfigError::IO(e))
                }
            }
        } else if url.contains("${password}") {
            error!("Found password placeholder '${{password}}' in database URL but no password was supplied: {}", url);
            Err(DatabaseConfigErrorKind::MissingPassword.into())
        } else {
            Ok(url)
        }
    }

    pub fn get_pool_options(&self) -> Result<PoolOptions<AnyDB>, ConfigError> {
        let options = PoolOptions::<AnyDB>::new();
        let options = options.max_lifetime(self.max_lifetime);
        let mut options = options.idle_timeout(self.idle_timeout);

        if let Some(size) = self.max_connections {
            options = options.max_connections(size);
        }

        if let Some(size) = self.min_connections {
            options = options.min_connections(size);
        }

        if let Some(duration) = self.connection_timeout {
            options = options.acquire_timeout(duration);
        }

        if let Some(b) = self.test_before_acquire {
            options = options.test_before_acquire(b);
        }

        Ok(options)
    }

    pub fn resolve(&self) -> Result<ResolvedDatabaseConfig, ConfigError> {
        let url = self.get_database_url()?;
        let pool_options = self.get_pool_options()?;

        Ok(ResolvedDatabaseConfig {
            url,
            pool_options,
        })
    }
}

#[derive(Debug)]
pub struct ResolvedDatabaseConfig {
    pub url: String,
    pub pool_options: PoolOptions<AnyDB>,
}