Skip to main content

ch_config/config/
database.rs

1use std::fmt;
2
3use url::form_urlencoded;
4
5use super::network::parse_network_url;
6use super::validation::{optional_text, redacted};
7use super::{ConfigError, Result};
8
9/// MariaDB connectivity settings.
10#[derive(Clone, Default, PartialEq, Eq)]
11pub struct MariadbConfig {
12    /// Base connection URL.
13    pub url: String,
14    /// Optional query parameters appended to the URL.
15    pub url_parameters: Option<String>,
16}
17
18impl fmt::Debug for MariadbConfig {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        formatter
21            .debug_struct("MariadbConfig")
22            .field("url", &redacted(&self.url))
23            .field(
24                "url_parameters",
25                &self.url_parameters.as_deref().map(redacted),
26            )
27            .finish()
28    }
29}
30
31impl MariadbConfig {
32    /// Creates a MariaDB configuration from a connection URL.
33    pub fn new(url: impl Into<String>) -> Self {
34        Self {
35            url: url.into(),
36            url_parameters: None,
37        }
38    }
39
40    pub(crate) fn apply_environment(
41        &mut self,
42        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
43    ) -> Result<()> {
44        match value("DATABASE_URL")? {
45            Some(url) => self.url = url,
46            None => {
47                if let Some(url) = mysql_environment_url(value)? {
48                    self.url = url;
49                }
50            }
51        }
52        if let Some(parameters) = value("DATABASE_URL_PARAMETERS")? {
53            self.url_parameters = Some(parameters);
54        }
55        Ok(())
56    }
57
58    /// Reports whether a database URL is configured.
59    pub fn is_configured(&self) -> bool {
60        !self.url.trim().is_empty()
61    }
62
63    /// Validates a configured MariaDB URL and its optional query parameters.
64    pub fn validate(&self) -> Result<()> {
65        self.parsed_url().map(|_| ())
66    }
67
68    fn parsed_url(&self) -> Result<Option<url::Url>> {
69        optional_text(
70            "mariadb.url",
71            (!self.url.is_empty()).then_some(self.url.as_str()),
72        )?;
73        optional_text("mariadb.url_parameters", self.url_parameters.as_deref())?;
74        let parsed = self
75            .is_configured()
76            .then(|| parse_mariadb_url(&self.url))
77            .transpose()?;
78        if let Some(parameters) = &self.url_parameters
79            && form_urlencoded::parse(parameters.trim().as_bytes())
80                .any(|(key, _)| key.trim().is_empty())
81        {
82            return Err(ConfigError::validation(
83                "mariadb.url_parameters",
84                "must contain non-empty query parameter names",
85            ));
86        }
87        Ok(parsed)
88    }
89
90    /// Builds the complete connection URL.
91    pub fn connection_url(&self) -> Result<String> {
92        let mut url = self.parsed_url()?.ok_or_else(|| {
93            ConfigError::validation(
94                "mariadb.url",
95                "must be configured before opening a connection",
96            )
97        })?;
98        if let Some(parameters) = &self.url_parameters {
99            url.query_pairs_mut()
100                .extend_pairs(form_urlencoded::parse(parameters.trim().as_bytes()));
101        }
102        Ok(url.into())
103    }
104}
105
106/// SpacetimeDB endpoint settings.
107#[derive(Clone, Default, PartialEq, Eq)]
108pub struct SpacetimeDbConfig {
109    /// Service endpoint URL.
110    pub url: String,
111}
112
113impl std::fmt::Debug for SpacetimeDbConfig {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("SpacetimeDbConfig")
116            .field("url", &redacted(&self.url))
117            .finish()
118    }
119}
120
121impl SpacetimeDbConfig {
122    /// Creates a SpacetimeDB configuration from its service endpoint.
123    pub fn new(url: impl Into<String>) -> Self {
124        Self { url: url.into() }
125    }
126
127    pub(crate) fn apply_environment(
128        &mut self,
129        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
130    ) -> Result<()> {
131        if let Some(url) = value("SPACETIMEDB_URL")? {
132            self.url = url;
133        }
134        Ok(())
135    }
136
137    /// Reports whether an endpoint is configured.
138    pub fn is_configured(&self) -> bool {
139        !self.url.trim().is_empty()
140    }
141
142    /// Validates a configured HTTP or HTTPS endpoint with a host.
143    pub fn validate(&self) -> Result<()> {
144        optional_text(
145            "spacetimedb.url",
146            (!self.url.is_empty()).then_some(self.url.as_str()),
147        )?;
148        if self.is_configured() {
149            parse_network_url(
150                "spacetimedb.url",
151                &self.url,
152                &["http", "https"],
153                false,
154                "must be an HTTP or HTTPS URL with a host and no embedded credentials",
155            )?;
156        }
157        Ok(())
158    }
159
160    /// Returns the configured endpoint.
161    pub fn endpoint_url(&self) -> Result<&str> {
162        self.validate()?;
163        if !self.is_configured() {
164            return Err(ConfigError::validation(
165                "spacetimedb.url",
166                "must be configured before opening a connection",
167            ));
168        }
169        Ok(self.url.trim())
170    }
171}
172
173fn mysql_environment_url(
174    value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
175) -> Result<Option<String>> {
176    let host = value("MYSQLHOST")?;
177    let port = value("MYSQLPORT")?;
178    let database = value("MYSQLDATABASE")?;
179    let user = value("MYSQLUSER")?;
180    let password = value("MYSQLPASSWORD")?;
181
182    if [&host, &port, &database, &user, &password]
183        .into_iter()
184        .all(Option::is_none)
185    {
186        return Ok(None);
187    }
188
189    let host = required_mysql_value("MYSQLHOST", host)?;
190    let database = required_mysql_value("MYSQLDATABASE", database)?;
191    let user = required_mysql_value("MYSQLUSER", user)?;
192    let password = required_mysql_value("MYSQLPASSWORD", password)?;
193    let port = port
194        .map(|value| {
195            value
196                .parse::<u16>()
197                .map_err(|_| ConfigError::VariableValue {
198                    variable: "MYSQLPORT",
199                    message: "must be an integer TCP port",
200                })
201        })
202        .transpose()?
203        .unwrap_or(3306);
204
205    let mut url =
206        url::Url::parse("mysql://localhost").expect("the static MariaDB base URL must be valid");
207    url.set_host(Some(&host))
208        .map_err(|_| ConfigError::VariableValue {
209            variable: "MYSQLHOST",
210            message: "must be a valid database host",
211        })?;
212    url.set_port(Some(port))
213        .map_err(|_| ConfigError::VariableValue {
214            variable: "MYSQLPORT",
215            message: "must be a valid TCP port",
216        })?;
217    url.set_username(&user)
218        .map_err(|()| ConfigError::VariableValue {
219            variable: "MYSQLUSER",
220            message: "contains unsupported URL data",
221        })?;
222    url.set_password(Some(&password))
223        .map_err(|()| ConfigError::VariableValue {
224            variable: "MYSQLPASSWORD",
225            message: "contains unsupported URL data",
226        })?;
227    url.set_path("");
228    url.path_segments_mut()
229        .expect("a mysql URL supports path segments")
230        .push(&database);
231
232    Ok(Some(url.into()))
233}
234
235fn required_mysql_value(variable: &'static str, value: Option<String>) -> Result<String> {
236    value.ok_or(ConfigError::VariableValue {
237        variable,
238        message: "is required when MYSQL connection variables are used",
239    })
240}
241
242fn parse_mariadb_url(value: &str) -> Result<url::Url> {
243    parse_network_url(
244        "mariadb.url",
245        value,
246        &["mysql"],
247        true,
248        "must be a mysql URL with a host",
249    )
250}
251
252#[cfg(test)]
253mod tests {
254    use std::collections::HashMap;
255
256    use super::*;
257
258    #[test]
259    fn railway_mysql_variables_form_a_percent_encoded_connection_url() {
260        let mut values = HashMap::from([
261            ("MYSQLHOST", "database.internal".to_owned()),
262            ("MYSQLPORT", "3307".to_owned()),
263            ("MYSQLDATABASE", "example database".to_owned()),
264            ("MYSQLUSER", "example user".to_owned()),
265            ("MYSQLPASSWORD", "secret:/@".to_owned()),
266        ]);
267        let mut config = MariadbConfig::default();
268
269        config
270            .apply_environment(&mut |name| Ok(values.remove(name)))
271            .expect("complete Railway variables should resolve");
272
273        let url = url::Url::parse(&config.connection_url().expect("connection URL"))
274            .expect("resolved URL should parse");
275        assert_eq!(url.host_str(), Some("database.internal"));
276        assert_eq!(url.port(), Some(3307));
277        assert_eq!(url.username(), "example%20user");
278        assert_eq!(url.password(), Some("secret%3A%2F%40"));
279        assert_eq!(url.path(), "/example%20database");
280    }
281
282    #[test]
283    fn partial_mysql_variables_fail_without_exposing_values() {
284        let mut values = HashMap::from([("MYSQLHOST", "database.internal".to_owned())]);
285        let error = MariadbConfig::default()
286            .apply_environment(&mut |name| Ok(values.remove(name)))
287            .expect_err("partial Railway variables should be rejected");
288
289        assert!(error.to_string().contains("MYSQLDATABASE"));
290        assert!(!error.to_string().contains("database.internal"));
291    }
292
293    #[test]
294    fn mariadb_requires_mysql_scheme_and_host() {
295        for invalid in [
296            "https://database.example.invalid/schema",
297            "mysql:///schema",
298            "not a url",
299        ] {
300            let config = MariadbConfig {
301                url: invalid.to_owned(),
302                url_parameters: None,
303            };
304            assert!(config.validate().is_err(), "{invalid} should be rejected");
305        }
306
307        MariadbConfig {
308            url: "mysql://user:secret@database.example.invalid/schema".to_owned(),
309            url_parameters: None,
310        }
311        .validate()
312        .expect("a MariaDB connection URL may contain credentials");
313    }
314
315    #[test]
316    fn connection_url_serializes_additional_parameters() {
317        let config = MariadbConfig {
318            url: "mysql://database.example.invalid/schema?existing=yes".to_owned(),
319            url_parameters: Some("tls=required&value=a%26b&fragment=%23safe".to_owned()),
320        };
321
322        let resolved = config.connection_url().expect("URL should resolve");
323        let parsed = url::Url::parse(&resolved).expect("resolved URL should parse");
324        let pairs = parsed.query_pairs().collect::<Vec<_>>();
325        assert!(pairs.contains(&("existing".into(), "yes".into())));
326        assert!(pairs.contains(&("tls".into(), "required".into())));
327        assert!(pairs.contains(&("value".into(), "a&b".into())));
328        assert!(pairs.contains(&("fragment".into(), "#safe".into())));
329        assert!(parsed.fragment().is_none());
330    }
331
332    #[test]
333    fn spacetimedb_requires_http_endpoint_without_credentials() {
334        for invalid in [
335            "ws://database.example.invalid",
336            "http:///missing-host",
337            "https://user:secret@database.example.invalid",
338        ] {
339            let config = SpacetimeDbConfig {
340                url: invalid.to_owned(),
341            };
342            assert!(config.validate().is_err(), "{invalid} should be rejected");
343        }
344
345        SpacetimeDbConfig {
346            url: "http://127.0.0.1:3000".to_owned(),
347        }
348        .validate()
349        .expect("local HTTP endpoint should be accepted");
350    }
351}