ch-config 0.2.0

Typed configuration loading with validation and secret-file support
Documentation
use std::fmt;

use url::form_urlencoded;

use super::network::parse_network_url;
use super::validation::{optional_text, redacted};
use super::{ConfigError, Result};

/// MariaDB connectivity settings.
#[derive(Clone, Default, PartialEq, Eq)]
pub struct MariadbConfig {
    /// Base connection URL.
    pub url: String,
    /// Optional query parameters appended to the URL.
    pub url_parameters: Option<String>,
}

impl fmt::Debug for MariadbConfig {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("MariadbConfig")
            .field("url", &redacted(&self.url))
            .field(
                "url_parameters",
                &self.url_parameters.as_deref().map(redacted),
            )
            .finish()
    }
}

impl MariadbConfig {
    /// Creates a MariaDB configuration from a connection URL.
    pub fn new(url: impl Into<String>) -> Self {
        Self {
            url: url.into(),
            url_parameters: None,
        }
    }

    pub(crate) fn apply_environment(
        &mut self,
        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
    ) -> Result<()> {
        match value("DATABASE_URL")? {
            Some(url) => self.url = url,
            None => {
                if let Some(url) = mysql_environment_url(value)? {
                    self.url = url;
                }
            }
        }
        if let Some(parameters) = value("DATABASE_URL_PARAMETERS")? {
            self.url_parameters = Some(parameters);
        }
        Ok(())
    }

    /// Reports whether a database URL is configured.
    pub fn is_configured(&self) -> bool {
        !self.url.trim().is_empty()
    }

    /// Validates a configured MariaDB URL and its optional query parameters.
    pub fn validate(&self) -> Result<()> {
        self.parsed_url().map(|_| ())
    }

    fn parsed_url(&self) -> Result<Option<url::Url>> {
        optional_text(
            "mariadb.url",
            (!self.url.is_empty()).then_some(self.url.as_str()),
        )?;
        optional_text("mariadb.url_parameters", self.url_parameters.as_deref())?;
        let parsed = self
            .is_configured()
            .then(|| parse_mariadb_url(&self.url))
            .transpose()?;
        if let Some(parameters) = &self.url_parameters
            && form_urlencoded::parse(parameters.trim().as_bytes())
                .any(|(key, _)| key.trim().is_empty())
        {
            return Err(ConfigError::validation(
                "mariadb.url_parameters",
                "must contain non-empty query parameter names",
            ));
        }
        Ok(parsed)
    }

    /// Builds the complete connection URL.
    pub fn connection_url(&self) -> Result<String> {
        let mut url = self.parsed_url()?.ok_or_else(|| {
            ConfigError::validation(
                "mariadb.url",
                "must be configured before opening a connection",
            )
        })?;
        if let Some(parameters) = &self.url_parameters {
            url.query_pairs_mut()
                .extend_pairs(form_urlencoded::parse(parameters.trim().as_bytes()));
        }
        Ok(url.into())
    }
}

/// SpacetimeDB endpoint settings.
#[derive(Clone, Default, PartialEq, Eq)]
pub struct SpacetimeDbConfig {
    /// Service endpoint URL.
    pub url: String,
}

impl std::fmt::Debug for SpacetimeDbConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SpacetimeDbConfig")
            .field("url", &redacted(&self.url))
            .finish()
    }
}

impl SpacetimeDbConfig {
    /// Creates a SpacetimeDB configuration from its service endpoint.
    pub fn new(url: impl Into<String>) -> Self {
        Self { url: url.into() }
    }

    pub(crate) fn apply_environment(
        &mut self,
        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
    ) -> Result<()> {
        if let Some(url) = value("SPACETIMEDB_URL")? {
            self.url = url;
        }
        Ok(())
    }

    /// Reports whether an endpoint is configured.
    pub fn is_configured(&self) -> bool {
        !self.url.trim().is_empty()
    }

    /// Validates a configured HTTP or HTTPS endpoint with a host.
    pub fn validate(&self) -> Result<()> {
        optional_text(
            "spacetimedb.url",
            (!self.url.is_empty()).then_some(self.url.as_str()),
        )?;
        if self.is_configured() {
            parse_network_url(
                "spacetimedb.url",
                &self.url,
                &["http", "https"],
                false,
                "must be an HTTP or HTTPS URL with a host and no embedded credentials",
            )?;
        }
        Ok(())
    }

    /// Returns the configured endpoint.
    pub fn endpoint_url(&self) -> Result<&str> {
        self.validate()?;
        if !self.is_configured() {
            return Err(ConfigError::validation(
                "spacetimedb.url",
                "must be configured before opening a connection",
            ));
        }
        Ok(self.url.trim())
    }
}

fn mysql_environment_url(
    value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
) -> Result<Option<String>> {
    let host = value("MYSQLHOST")?;
    let port = value("MYSQLPORT")?;
    let database = value("MYSQLDATABASE")?;
    let user = value("MYSQLUSER")?;
    let password = value("MYSQLPASSWORD")?;

    if [&host, &port, &database, &user, &password]
        .into_iter()
        .all(Option::is_none)
    {
        return Ok(None);
    }

    let host = required_mysql_value("MYSQLHOST", host)?;
    let database = required_mysql_value("MYSQLDATABASE", database)?;
    let user = required_mysql_value("MYSQLUSER", user)?;
    let password = required_mysql_value("MYSQLPASSWORD", password)?;
    let port = port
        .map(|value| {
            value
                .parse::<u16>()
                .map_err(|_| ConfigError::VariableValue {
                    variable: "MYSQLPORT",
                    message: "must be an integer TCP port",
                })
        })
        .transpose()?
        .unwrap_or(3306);

    let mut url =
        url::Url::parse("mysql://localhost").expect("the static MariaDB base URL must be valid");
    url.set_host(Some(&host))
        .map_err(|_| ConfigError::VariableValue {
            variable: "MYSQLHOST",
            message: "must be a valid database host",
        })?;
    url.set_port(Some(port))
        .map_err(|_| ConfigError::VariableValue {
            variable: "MYSQLPORT",
            message: "must be a valid TCP port",
        })?;
    url.set_username(&user)
        .map_err(|()| ConfigError::VariableValue {
            variable: "MYSQLUSER",
            message: "contains unsupported URL data",
        })?;
    url.set_password(Some(&password))
        .map_err(|()| ConfigError::VariableValue {
            variable: "MYSQLPASSWORD",
            message: "contains unsupported URL data",
        })?;
    url.set_path("");
    url.path_segments_mut()
        .expect("a mysql URL supports path segments")
        .push(&database);

    Ok(Some(url.into()))
}

fn required_mysql_value(variable: &'static str, value: Option<String>) -> Result<String> {
    value.ok_or(ConfigError::VariableValue {
        variable,
        message: "is required when MYSQL connection variables are used",
    })
}

fn parse_mariadb_url(value: &str) -> Result<url::Url> {
    parse_network_url(
        "mariadb.url",
        value,
        &["mysql"],
        true,
        "must be a mysql URL with a host",
    )
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;

    #[test]
    fn railway_mysql_variables_form_a_percent_encoded_connection_url() {
        let mut values = HashMap::from([
            ("MYSQLHOST", "database.internal".to_owned()),
            ("MYSQLPORT", "3307".to_owned()),
            ("MYSQLDATABASE", "example database".to_owned()),
            ("MYSQLUSER", "example user".to_owned()),
            ("MYSQLPASSWORD", "secret:/@".to_owned()),
        ]);
        let mut config = MariadbConfig::default();

        config
            .apply_environment(&mut |name| Ok(values.remove(name)))
            .expect("complete Railway variables should resolve");

        let url = url::Url::parse(&config.connection_url().expect("connection URL"))
            .expect("resolved URL should parse");
        assert_eq!(url.host_str(), Some("database.internal"));
        assert_eq!(url.port(), Some(3307));
        assert_eq!(url.username(), "example%20user");
        assert_eq!(url.password(), Some("secret%3A%2F%40"));
        assert_eq!(url.path(), "/example%20database");
    }

    #[test]
    fn partial_mysql_variables_fail_without_exposing_values() {
        let mut values = HashMap::from([("MYSQLHOST", "database.internal".to_owned())]);
        let error = MariadbConfig::default()
            .apply_environment(&mut |name| Ok(values.remove(name)))
            .expect_err("partial Railway variables should be rejected");

        assert!(error.to_string().contains("MYSQLDATABASE"));
        assert!(!error.to_string().contains("database.internal"));
    }

    #[test]
    fn mariadb_requires_mysql_scheme_and_host() {
        for invalid in [
            "https://database.example.invalid/schema",
            "mysql:///schema",
            "not a url",
        ] {
            let config = MariadbConfig {
                url: invalid.to_owned(),
                url_parameters: None,
            };
            assert!(config.validate().is_err(), "{invalid} should be rejected");
        }

        MariadbConfig {
            url: "mysql://user:secret@database.example.invalid/schema".to_owned(),
            url_parameters: None,
        }
        .validate()
        .expect("a MariaDB connection URL may contain credentials");
    }

    #[test]
    fn connection_url_serializes_additional_parameters() {
        let config = MariadbConfig {
            url: "mysql://database.example.invalid/schema?existing=yes".to_owned(),
            url_parameters: Some("tls=required&value=a%26b&fragment=%23safe".to_owned()),
        };

        let resolved = config.connection_url().expect("URL should resolve");
        let parsed = url::Url::parse(&resolved).expect("resolved URL should parse");
        let pairs = parsed.query_pairs().collect::<Vec<_>>();
        assert!(pairs.contains(&("existing".into(), "yes".into())));
        assert!(pairs.contains(&("tls".into(), "required".into())));
        assert!(pairs.contains(&("value".into(), "a&b".into())));
        assert!(pairs.contains(&("fragment".into(), "#safe".into())));
        assert!(parsed.fragment().is_none());
    }

    #[test]
    fn spacetimedb_requires_http_endpoint_without_credentials() {
        for invalid in [
            "ws://database.example.invalid",
            "http:///missing-host",
            "https://user:secret@database.example.invalid",
        ] {
            let config = SpacetimeDbConfig {
                url: invalid.to_owned(),
            };
            assert!(config.validate().is_err(), "{invalid} should be rejected");
        }

        SpacetimeDbConfig {
            url: "http://127.0.0.1:3000".to_owned(),
        }
        .validate()
        .expect("local HTTP endpoint should be accepted");
    }
}