1use std::collections::BTreeMap;
6use std::str::FromStr;
7
8#[derive(Debug, Clone)]
9pub enum UrlError {
10 MissingScheme,
11 MissingAuthorityDelimiter,
12 PortNotU16(String),
13 MissingAuthorityHost,
14}
15
16#[derive(Debug, Clone, Eq, PartialEq)]
17pub enum Scheme {
18 Http,
19 Https,
20 Ws,
21 Wss,
22 Other(String),
23}
24
25#[derive(Default, Debug, Clone, Eq, PartialEq)]
26pub struct URL {
27 pub scheme: String,
28 pub username: Option<String>,
29 pub password: Option<String>,
30 pub host: String,
31 pub port: Option<u16>,
32 pub path: Option<String>,
33 pub query_parts: BTreeMap<String, String>,
34 pub fragment: Option<String>,
35}
36
37#[derive(Default)]
38pub struct Authority {
39 pub username: Option<String>,
40 pub password: Option<String>,
41 pub host: String,
42 pub port: Option<u16>,
43}
44
45impl Authority {
46 fn try_from(value: &str) -> Result<(Authority, &str), UrlError> {
47 if !value.starts_with("//") {
48 return Err(UrlError::MissingAuthorityDelimiter);
49 };
50 let (_, value) = value.split_at(2);
51 let split = value.find(['/', '?', '#']).unwrap_or(value.len());
52 let (authority, remaining) = value.split_at(split);
53 let mut out = Authority::default();
54 let hostport = if let Some(at_idx) = authority.find('@') {
55 let (userinfo, hostport) = authority.split_at(at_idx);
56 let (_, hostport) = hostport.split_at(1);
57 if let Some(col_idx) = userinfo.find(':') {
58 let (user, pass) = userinfo.split_at(col_idx);
59 let (_, pass) = pass.split_at(1);
60 out.username = Some(user.to_string());
61 out.password = Some(pass.to_string());
62 } else {
63 out.username = Some(userinfo.to_string());
64 }
65 hostport
66 } else {
67 authority
68 };
69 if let Some(col_idx) = hostport.find(':') {
70 let (host, port) = hostport.split_at(col_idx);
71 out.host = host.to_string();
72 let (_, port) = port.split_at(1);
73 let Ok(port) = u16::from_str(port) else {
74 return Err(UrlError::PortNotU16(port.to_string()));
75 };
76 out.port = Some(port);
77 } else {
78 out.host = hostport.to_string();
79 }
80 if out.host.is_empty() {
81 return Err(UrlError::MissingAuthorityHost);
82 }
83 Ok((out, remaining))
84 }
85}
86
87impl FromStr for URL {
88 type Err = UrlError;
89
90 fn from_str(value: &str) -> Result<Self, Self::Err> {
91 let Some(scheme_idx) = value.find(':') else {
92 return Err(UrlError::MissingScheme);
93 };
94 let mut out = URL::default();
95 let (scheme, rest) = value.split_at(scheme_idx);
96 out.scheme = scheme.to_string();
97 match scheme {
98 "http" | "ws" => out.port = Some(80),
99 "https" | "wss" => out.port = Some(443),
100 _ => {}
101 };
102 let (_, rest) = rest.split_at(1);
103 if !rest.starts_with("//") {
104 return Err(UrlError::MissingAuthorityHost);
105 }
106 let (authority, rest) = Authority::try_from(rest)?;
107 out.username = authority.username;
108 out.password = authority.password;
109 out.host = authority.host;
110 out.port = authority.port;
111 if let Some(next_idx) = rest.find(['?', '#']) {
112 let (path, rest) = rest.split_at(next_idx);
113 out.path = Some(path.to_string());
114
115 let query = if let Some(next_idx) = rest.find('#') {
116 let (query, frag) = rest.split_at(next_idx);
117 let (_, frag) = frag.split_at(1);
118 out.fragment = Some(frag.to_string());
119 query
120 } else {
121 rest
122 };
123 if !query.is_empty() {
124 let (_, query) = query.split_at(1);
125 for item in query.split('&') {
126 let (key, val) = item.split_at(item.find('=').unwrap_or(item.len()));
127 out.query_parts.insert(key.to_string(), val.to_string());
128 }
129 }
130 } else if !rest.is_empty() {
131 out.path = Some(rest.to_string());
132 };
133
134 if let Some(pth) = &out.path {
135 if pth.is_empty() {
136 out.path = Some("/".to_string());
137 }
138 } else if out.path.is_none() {
139 out.path = Some("/".to_string());
140 }
141
142 Ok(out)
143 }
144}
145
146impl TryFrom<String> for URL {
147 type Error = UrlError;
148
149 fn try_from(value: String) -> Result<URL, Self::Error> {
150 FromStr::from_str(&value)
151 }
152}
153
154impl URL {
155 pub fn scheme(&self) -> &str {
156 &self.scheme
157 }
158 pub fn username(&self) -> Option<&String> {
159 self.username.as_ref()
160 }
161 pub fn password(&self) -> Option<&String> {
162 self.password.as_ref()
163 }
164 pub fn host(&self) -> &str {
165 &self.host
166 }
167 pub fn port(&self) -> Option<u16> {
168 self.port
169 }
170 pub fn path(&self) -> Option<&String> {
171 self.path.as_ref()
172 }
173 pub fn query_parts(&self) -> &BTreeMap<String, String> {
174 &self.query_parts
175 }
176 pub fn fragment(&self) -> Option<&String> {
177 self.fragment.as_ref()
178 }
179
180 pub fn get_path_query_fragment(&self) -> String {
181 let mut out = String::new();
182 if let Some(path) = self.path() {
183 if !path.starts_with('/') {
184 out.push('/');
185 }
186 out.push_str(path);
187 } else {
188 out.push('/');
189 }
190 let query = self.query_parts();
191 if !query.is_empty() {
192 out.push('?');
193 out.push_str(
194 &query
195 .iter()
196 .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
197 .collect::<Vec<String>>()
198 .join("&"),
199 );
200 }
201 if let Some(fragment) = self.fragment() {
202 out.push('#');
203 out.push_str(&url_encode(fragment));
204 }
205 out
206 }
207}
208
209#[derive(Clone)]
210pub struct URLBuilder {
211 url: URL,
212}
213impl URLBuilder {
214 pub fn new(scheme: &str, host: &str) -> URLBuilder {
215 URLBuilder {
216 url: URL {
217 scheme: scheme.to_string(),
218 username: None,
219 password: None,
220 host: host.to_string(),
221 port: None,
222 path: None,
223 query_parts: Default::default(),
224 fragment: None,
225 },
226 }
227 }
228
229 pub fn with_username(&mut self, username: &str) -> &mut Self {
230 self.url.username = Some(username.to_string());
231 self
232 }
233 pub fn with_password(&mut self, password: &str) -> &mut Self {
234 self.url.password = Some(password.to_string());
235 self
236 }
237 pub fn with_port(&mut self, port: u16) -> &mut Self {
238 self.url.port = Some(port);
239 self
240 }
241 pub fn with_path(&mut self, path: &str) -> &mut Self {
242 self.url.path = Some(path.to_string());
243 self
244 }
245 pub fn add_query(&mut self, key: &str, val: &str) -> &mut Self {
246 self.url
247 .query_parts
248 .insert(key.to_string(), val.to_string());
249 self
250 }
251 pub fn with_fragment(&mut self, frag: &str) -> &mut Self {
252 self.url.fragment = Some(frag.to_string());
253 self
254 }
255 pub fn build(self) -> URL {
256 self.url
257 }
258}
259
260#[macro_export]
261macro_rules! url {
262 ($scheme:literal, $host:literal) => {{
263 $crate::url::URLBuilder::new($scheme, $host).build()
264 }};
265 ($scheme:literal, $host:literal, $path:literal) => {{
266 let mut tmp = $crate::url::URLBuilder::new($scheme, $host);
267 tmp.with_path($path);
268 tmp.build()
269 }};
270 ($scheme:literal, $host:literal, $path:literal, $frag:literal) => {{
271 let mut tmp = $crate::url::URLBuilder::new($scheme, $host);
272 tmp.with_path($path);
273 tmp.with_fragment($frag);
274 tmp.build()
275 }};
276 ($scheme:literal, $host:literal, $path:literal,{$($qk:literal,$qv:literal)+}) => {{
277 let mut tmp = $crate::url::URLBuilder::new($scheme, $host);
278 tmp.with_path($path);
279 $(
280 tmp.add_query($qk,$qv);
281 )+
282 tmp.build()
283 }};
284 ($scheme:literal, $host:literal, $path:literal, $frag:literal, {$($qk:literal,$qv:literal)+}) => {{
285 let mut tmp = $crate::url::URLBuilder::new($scheme, $host);
286 tmp.with_path($path);
287 tmp.with_fragment($frag);
288 $(
289 tmp.add_query($qk,$qv);
290 )+
291 tmp.build()
292 }};
293}
294
295pub fn url_encode<T: AsRef<str>>(input: T) -> String {
296 let input = input.as_ref();
297 let mut out = String::with_capacity(input.len());
298
299 for ch in input.chars() {
300 let add = match ch {
301 ' ' => "%20",
302 '!' => "%21",
303 '\"' => "%22",
304 '#' => "%23",
305 '$' => "%24",
306 '%' => "%25",
307 '&' => "%26",
308 '\'' => "%27",
309 '(' => "%28",
310 ')' => "%29",
311 '*' => "%2A",
312 '+' => "%2B",
313 ',' => "%2C",
314 '/' => "%2F",
315 ':' => "%3A",
316 ';' => "%3B",
317 '=' => "%3D",
318 '?' => "%3F",
319 '@' => "%40",
320 '[' => "%5B",
321 ']' => "%5D",
322 v => {
323 out.push(v);
324 ""
325 }
326 };
327 out.push_str(add);
328 }
329
330 out
331}
332
333#[cfg(test)]
334mod tests {
335 use crate::url::{UrlError, URL};
336 use std::str::FromStr;
337
338 #[allow(clippy::panic_in_result_fn)]
339 #[test]
340 pub fn test() -> Result<(), UrlError> {
341 let url = "https://user:password@host:80/path?query#seg";
342 let url: URL = URL::from_str(url)?;
343
344 assert_eq!("https", url.scheme);
345 assert_eq!(Some("user".to_string()), url.username);
346 assert_eq!(Some("password".to_string()), url.password);
347 assert_eq!("host".to_string(), url.host);
348 assert_eq!(Some(80), url.port);
349 assert_eq!(Some("/path".to_string()), url.path);
350 assert_eq!(Some("seg".to_string()), url.fragment);
351
352 Ok(())
353 }
354
355 #[allow(clippy::panic_in_result_fn)]
356 #[test]
357 pub fn tests() -> Result<(), UrlError> {
358 let mut tests: Vec<(URL, &str)> = Vec::new();
359 tests.push((url!("http", "a", "/b/c/g"), "http://a/b/c/g"));
360 tests.push((url!("http", "a", "/b/c/g/"), "http://a/b/c/g/"));
361 tests.push((
362 url!("http", "a", "/b/c/g;p", {"y",""}),
363 "http://a/b/c/g;p?y",
364 ));
365 tests.push((url!("http", "a", "/b/c/g", {"y",""}), "http://a/b/c/g?y"));
366 tests.push((
367 url!("http", "a", "/b/c/d;p","s", {"q",""}),
368 "http://a/b/c/d;p?q#s",
369 ));
370 tests.push((
371 url!("http", "a", "/b/c/g", "s/../x"),
372 "http://a/b/c/g#s/../x",
373 ));
374
375 for (url, chk) in tests {
376 let chk: URL = URL::from_str(chk)?;
377 assert_eq!(url, chk);
378 }
379
380 Ok(())
381 }
382}