schema_core/common/
connection_url.rs1use nutype::nutype;
2use std::fmt;
3
4#[nutype(
10 sanitize(trim),
11 validate(regex = r"^(postgresql|postgres)://\S+$"),
12 derive(Clone, AsRef, Deref, Hash, Eq, PartialEq, Serialize, Deserialize)
13)]
14pub struct ConnectionUrl(String);
15
16fn redact_password(url: &str) -> String {
20 let Some(after_scheme) = url.find("://").map(|i| i + 3) else {
21 return url.to_owned();
22 };
23 let Some(at) = url[after_scheme..].find('@').map(|i| after_scheme + i) else {
24 return url.to_owned();
25 };
26 let userinfo = &url[after_scheme..at];
27 match userinfo.find(':') {
28 Some(colon) => format!(
29 "{}{}:***{}",
30 &url[..after_scheme],
31 &userinfo[..colon],
32 &url[at..]
33 ),
34 None => url.to_owned(),
35 }
36}
37
38impl fmt::Display for ConnectionUrl {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 f.write_str(&redact_password(self.as_ref()))
42 }
43}
44
45impl fmt::Debug for ConnectionUrl {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 write!(f, "ConnectionUrl({})", redact_password(self.as_ref()))
49 }
50}
51
52#[derive(Debug, Clone, Default)]
53pub enum Scheme {
54 #[default]
55 Postgresql,
56 Postgres,
57}
58
59impl fmt::Display for Scheme {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 match self {
62 Self::Postgresql => write!(f, "postgresql"),
63 Self::Postgres => write!(f, "postgres"),
64 }
65 }
66}
67
68#[bon::bon]
69impl ConnectionUrl {
70 #[builder]
71 pub fn from_parts(
72 #[builder(default)] scheme: Scheme,
73 #[builder(into)] username: String,
74 #[builder(into)] password: Option<String>,
75 #[builder(into)] host: String,
76 port: Option<u16>,
77 #[builder(into)] database: Option<String>,
78 ) -> Result<Self, ConnectionUrlError> {
79 let mut url = format!("{}://{}", scheme, username);
80
81 if let Some(pwd) = password {
82 url.push(':');
83 url.push_str(&pwd);
84 }
85
86 url.push('@');
87 url.push_str(&host);
88
89 if let Some(p) = port {
90 url.push(':');
91 url.push_str(&p.to_string());
92 }
93
94 if let Some(db) = database {
95 url.push('/');
96 url.push_str(&db);
97 }
98
99 Self::try_new(url)
100 }
101}
102
103#[cfg(test)]
104#[allow(clippy::unwrap_used)]
105mod tests {
106 use super::*;
107
108 #[test]
111 fn valid_full_url() {
112 assert!(ConnectionUrl::try_new("postgresql://user:pass@localhost:5432/mydb").is_ok());
113 }
114
115 #[test]
116 fn valid_minimal_url() {
117 assert!(ConnectionUrl::try_new("postgresql://user@localhost").is_ok());
118 }
119
120 #[test]
121 fn valid_postgres_alias_scheme() {
122 assert!(ConnectionUrl::try_new("postgres://user@localhost/db").is_ok());
123 }
124
125 #[test]
126 fn valid_no_port() {
127 assert!(ConnectionUrl::try_new("postgresql://user:pass@db.example.com/mydb").is_ok());
128 }
129
130 #[test]
131 fn valid_no_database() {
132 assert!(ConnectionUrl::try_new("postgresql://user@localhost:5432").is_ok());
133 }
134
135 #[test]
136 fn sanitizes_surrounding_whitespace() {
137 let url = ConnectionUrl::try_new(" postgresql://user@localhost ").unwrap();
138 assert_eq!(url.to_string(), "postgresql://user@localhost");
139 }
140
141 #[test]
144 fn invalid_empty_string() {
145 assert!(ConnectionUrl::try_new("").is_err());
146 }
147
148 #[test]
149 fn invalid_no_scheme() {
150 assert!(ConnectionUrl::try_new("user:pass@localhost/db").is_err());
151 }
152
153 #[test]
154 fn invalid_unsupported_scheme() {
155 assert!(ConnectionUrl::try_new("mysql://user@localhost/db").is_err());
156 }
157
158 #[test]
159 fn invalid_http_scheme() {
160 assert!(ConnectionUrl::try_new("http://user@localhost").is_err());
161 }
162
163 #[test]
164 fn invalid_scheme_only() {
165 assert!(ConnectionUrl::try_new("postgresql://").is_err());
166 }
167
168 #[test]
169 fn invalid_whitespace_inside_url() {
170 assert!(ConnectionUrl::try_new("postgresql://user @localhost").is_err());
171 }
172
173 #[test]
176 fn builder_full_url() {
177 let url = ConnectionUrl::from_parts()
178 .scheme(Scheme::Postgresql)
179 .username("user")
180 .password("s3cr3t")
181 .host("db.example.com")
182 .port(5432_u16)
183 .database("mydb")
184 .call()
185 .unwrap();
186 assert_eq!(
188 url.as_ref(),
189 "postgresql://user:s3cr3t@db.example.com:5432/mydb"
190 );
191 assert_eq!(
192 url.to_string(),
193 "postgresql://user:***@db.example.com:5432/mydb"
194 );
195 }
196
197 #[test]
198 fn builder_minimal_url() {
199 let url = ConnectionUrl::from_parts()
200 .username("user")
201 .host("localhost")
202 .call()
203 .unwrap();
204 assert_eq!(url.to_string(), "postgresql://user@localhost");
205 }
206
207 #[test]
208 fn builder_default_scheme_is_postgresql() {
209 let url = ConnectionUrl::from_parts()
210 .username("user")
211 .host("localhost")
212 .call()
213 .unwrap();
214 assert!(url.to_string().starts_with("postgresql://"));
215 }
216
217 #[test]
218 fn builder_postgres_scheme() {
219 let url = ConnectionUrl::from_parts()
220 .scheme(Scheme::Postgres)
221 .username("user")
222 .host("localhost")
223 .call()
224 .unwrap();
225 assert!(url.to_string().starts_with("postgres://"));
226 }
227
228 #[test]
229 fn builder_omits_optional_parts_when_absent() {
230 let url = ConnectionUrl::from_parts()
231 .username("user")
232 .host("localhost")
233 .call()
234 .unwrap()
235 .to_string();
236 assert_eq!(url, "postgresql://user@localhost");
237 }
238}