use std::fmt;
use url::form_urlencoded;
use super::network::parse_network_url;
use super::validation::{optional_text, redacted};
use super::{ConfigError, Result};
#[derive(Clone, Default, PartialEq, Eq)]
pub struct MariadbConfig {
pub url: String,
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 {
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(())
}
pub fn is_configured(&self) -> bool {
!self.url.trim().is_empty()
}
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)
}
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())
}
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct SpacetimeDbConfig {
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 {
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(())
}
pub fn is_configured(&self) -> bool {
!self.url.trim().is_empty()
}
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(())
}
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");
}
}