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#[derive(Clone, Default, PartialEq, Eq)]
11pub struct MariadbConfig {
12 pub url: String,
14 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 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 pub fn is_configured(&self) -> bool {
60 !self.url.trim().is_empty()
61 }
62
63 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 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#[derive(Clone, Default, PartialEq, Eq)]
108pub struct SpacetimeDbConfig {
109 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 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 pub fn is_configured(&self) -> bool {
139 !self.url.trim().is_empty()
140 }
141
142 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 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}