use std::collections::BTreeMap;
use std::str::FromStr;
#[derive(Debug, Clone)]
pub enum UrlError {
MissingScheme,
MissingAuthorityDelimiter,
PortNotU16(String),
MissingAuthorityHost,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Scheme {
Http,
Https,
Ws,
Wss,
Other(String),
}
#[derive(Default, Debug, Clone, Eq, PartialEq)]
pub struct URL {
pub scheme: String,
pub username: Option<String>,
pub password: Option<String>,
pub host: String,
pub port: Option<u16>,
pub path: Option<String>,
pub query_parts: BTreeMap<String, String>,
pub fragment: Option<String>,
}
#[derive(Default)]
pub struct Authority {
pub username: Option<String>,
pub password: Option<String>,
pub host: String,
pub port: Option<u16>,
}
impl Authority {
fn try_from(value: &str) -> Result<(Authority, &str), UrlError> {
if !value.starts_with("//") {
return Err(UrlError::MissingAuthorityDelimiter);
};
let (_, value) = value.split_at(2);
let split = value.find(['/', '?', '#']).unwrap_or(value.len());
let (authority, remaining) = value.split_at(split);
let mut out = Authority::default();
let hostport = if let Some(at_idx) = authority.find('@') {
let (userinfo, hostport) = authority.split_at(at_idx);
let (_, hostport) = hostport.split_at(1);
if let Some(col_idx) = userinfo.find(':') {
let (user, pass) = userinfo.split_at(col_idx);
let (_, pass) = pass.split_at(1);
out.username = Some(user.to_string());
out.password = Some(pass.to_string());
} else {
out.username = Some(userinfo.to_string());
}
hostport
} else {
authority
};
if let Some(col_idx) = hostport.find(':') {
let (host, port) = hostport.split_at(col_idx);
out.host = host.to_string();
let (_, port) = port.split_at(1);
let Ok(port) = u16::from_str(port) else {
return Err(UrlError::PortNotU16(port.to_string()));
};
out.port = Some(port);
} else {
out.host = hostport.to_string();
}
if out.host.is_empty() {
return Err(UrlError::MissingAuthorityHost);
}
Ok((out, remaining))
}
}
impl FromStr for URL {
type Err = UrlError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let Some(scheme_idx) = value.find(':') else {
return Err(UrlError::MissingScheme);
};
let mut out = URL::default();
let (scheme, rest) = value.split_at(scheme_idx);
out.scheme = scheme.to_string();
match scheme {
"http" | "ws" => out.port = Some(80),
"https" | "wss" => out.port = Some(443),
_ => {}
};
let (_, rest) = rest.split_at(1);
if !rest.starts_with("//") {
return Err(UrlError::MissingAuthorityHost);
}
let (authority, rest) = Authority::try_from(rest)?;
out.username = authority.username;
out.password = authority.password;
out.host = authority.host;
out.port = authority.port;
if let Some(next_idx) = rest.find(['?', '#']) {
let (path, rest) = rest.split_at(next_idx);
out.path = Some(path.to_string());
let query = if let Some(next_idx) = rest.find('#') {
let (query, frag) = rest.split_at(next_idx);
let (_, frag) = frag.split_at(1);
out.fragment = Some(frag.to_string());
query
} else {
rest
};
if !query.is_empty() {
let (_, query) = query.split_at(1);
for item in query.split('&') {
let (key, val) = item.split_at(item.find('=').unwrap_or(item.len()));
out.query_parts.insert(key.to_string(), val.to_string());
}
}
} else if !rest.is_empty() {
out.path = Some(rest.to_string());
};
if let Some(pth) = &out.path {
if pth.is_empty() {
out.path = Some("/".to_string());
}
} else if out.path.is_none() {
out.path = Some("/".to_string());
}
Ok(out)
}
}
impl TryFrom<String> for URL {
type Error = UrlError;
fn try_from(value: String) -> Result<URL, Self::Error> {
FromStr::from_str(&value)
}
}
impl URL {
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn username(&self) -> Option<&String> {
self.username.as_ref()
}
pub fn password(&self) -> Option<&String> {
self.password.as_ref()
}
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> Option<u16> {
self.port
}
pub fn path(&self) -> Option<&String> {
self.path.as_ref()
}
pub fn query_parts(&self) -> &BTreeMap<String, String> {
&self.query_parts
}
pub fn fragment(&self) -> Option<&String> {
self.fragment.as_ref()
}
pub fn get_path_query_fragment(&self) -> String {
let mut out = String::new();
if let Some(path) = self.path() {
if !path.starts_with('/') {
out.push('/');
}
out.push_str(path);
} else {
out.push('/');
}
let query = self.query_parts();
if !query.is_empty() {
out.push('?');
out.push_str(
&query
.iter()
.map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
.collect::<Vec<String>>()
.join("&"),
);
}
if let Some(fragment) = self.fragment() {
out.push('#');
out.push_str(&url_encode(fragment));
}
out
}
}
#[derive(Clone)]
pub struct URLBuilder {
url: URL,
}
impl URLBuilder {
pub fn new(scheme: &str, host: &str) -> URLBuilder {
URLBuilder {
url: URL {
scheme: scheme.to_string(),
username: None,
password: None,
host: host.to_string(),
port: None,
path: None,
query_parts: Default::default(),
fragment: None,
},
}
}
pub fn with_username(&mut self, username: &str) -> &mut Self {
self.url.username = Some(username.to_string());
self
}
pub fn with_password(&mut self, password: &str) -> &mut Self {
self.url.password = Some(password.to_string());
self
}
pub fn with_port(&mut self, port: u16) -> &mut Self {
self.url.port = Some(port);
self
}
pub fn with_path(&mut self, path: &str) -> &mut Self {
self.url.path = Some(path.to_string());
self
}
pub fn add_query(&mut self, key: &str, val: &str) -> &mut Self {
self.url
.query_parts
.insert(key.to_string(), val.to_string());
self
}
pub fn with_fragment(&mut self, frag: &str) -> &mut Self {
self.url.fragment = Some(frag.to_string());
self
}
pub fn build(self) -> URL {
self.url
}
}
#[macro_export]
macro_rules! url {
($scheme:literal, $host:literal) => {{
$crate::url::URLBuilder::new($scheme, $host).build()
}};
($scheme:literal, $host:literal, $path:literal) => {{
let mut tmp = $crate::url::URLBuilder::new($scheme, $host);
tmp.with_path($path);
tmp.build()
}};
($scheme:literal, $host:literal, $path:literal, $frag:literal) => {{
let mut tmp = $crate::url::URLBuilder::new($scheme, $host);
tmp.with_path($path);
tmp.with_fragment($frag);
tmp.build()
}};
($scheme:literal, $host:literal, $path:literal,{$($qk:literal,$qv:literal)+}) => {{
let mut tmp = $crate::url::URLBuilder::new($scheme, $host);
tmp.with_path($path);
$(
tmp.add_query($qk,$qv);
)+
tmp.build()
}};
($scheme:literal, $host:literal, $path:literal, $frag:literal, {$($qk:literal,$qv:literal)+}) => {{
let mut tmp = $crate::url::URLBuilder::new($scheme, $host);
tmp.with_path($path);
tmp.with_fragment($frag);
$(
tmp.add_query($qk,$qv);
)+
tmp.build()
}};
}
pub fn url_encode<T: AsRef<str>>(input: T) -> String {
let input = input.as_ref();
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
let add = match ch {
' ' => "%20",
'!' => "%21",
'\"' => "%22",
'#' => "%23",
'$' => "%24",
'%' => "%25",
'&' => "%26",
'\'' => "%27",
'(' => "%28",
')' => "%29",
'*' => "%2A",
'+' => "%2B",
',' => "%2C",
'/' => "%2F",
':' => "%3A",
';' => "%3B",
'=' => "%3D",
'?' => "%3F",
'@' => "%40",
'[' => "%5B",
']' => "%5D",
v => {
out.push(v);
""
}
};
out.push_str(add);
}
out
}
#[cfg(test)]
mod tests {
use crate::url::{UrlError, URL};
use std::str::FromStr;
#[allow(clippy::panic_in_result_fn)]
#[test]
pub fn test() -> Result<(), UrlError> {
let url = "https://user:password@host:80/path?query#seg";
let url: URL = URL::from_str(url)?;
assert_eq!("https", url.scheme);
assert_eq!(Some("user".to_string()), url.username);
assert_eq!(Some("password".to_string()), url.password);
assert_eq!("host".to_string(), url.host);
assert_eq!(Some(80), url.port);
assert_eq!(Some("/path".to_string()), url.path);
assert_eq!(Some("seg".to_string()), url.fragment);
Ok(())
}
#[allow(clippy::panic_in_result_fn)]
#[test]
pub fn tests() -> Result<(), UrlError> {
let mut tests: Vec<(URL, &str)> = Vec::new();
tests.push((url!("http", "a", "/b/c/g"), "http://a/b/c/g"));
tests.push((url!("http", "a", "/b/c/g/"), "http://a/b/c/g/"));
tests.push((
url!("http", "a", "/b/c/g;p", {"y",""}),
"http://a/b/c/g;p?y",
));
tests.push((url!("http", "a", "/b/c/g", {"y",""}), "http://a/b/c/g?y"));
tests.push((
url!("http", "a", "/b/c/d;p","s", {"q",""}),
"http://a/b/c/d;p?q#s",
));
tests.push((
url!("http", "a", "/b/c/g", "s/../x"),
"http://a/b/c/g#s/../x",
));
for (url, chk) in tests {
let chk: URL = URL::from_str(chk)?;
assert_eq!(url, chk);
}
Ok(())
}
}