irox_networking/
url.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2023 IROX Contributors
3//
4
5use 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}