use std::fmt;
use crate::error::{Error, ErrorKind, Result};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Url {
raw: String,
scheme: String,
authority: String,
host: String,
port: Option<u16>,
path_and_query: String,
}
impl Url {
pub fn parse(input: impl AsRef<str>) -> Result<Self> {
let input = input.as_ref().trim();
if input.is_empty() {
return Err(Error::new(ErrorKind::InvalidUrl, "url is empty"));
}
if !input.contains("://") {
return Err(Error::new(
ErrorKind::InvalidUrl,
format!("url must contain scheme: {input}"),
));
}
let (scheme, rest) = input.split_once("://").ok_or_else(|| {
Error::new(
ErrorKind::InvalidUrl,
format!("url must contain scheme: {input}"),
)
})?;
if scheme.is_empty() {
return Err(Error::new(ErrorKind::InvalidUrl, "url scheme is empty"));
}
let (authority, path_and_query) = match rest.find('/') {
Some(index) => (&rest[..index], &rest[index..]),
None => (rest, "/"),
};
if authority.is_empty() {
return Err(Error::new(ErrorKind::InvalidUrl, "url authority is empty"));
}
let (host, port) = parse_authority(authority)?;
Ok(Self {
raw: input.to_owned(),
scheme: scheme.to_owned(),
authority: authority.to_owned(),
host,
port,
path_and_query: path_and_query.to_owned(),
})
}
pub fn join(&self, path: impl AsRef<str>) -> Result<Self> {
let path = path.as_ref();
if path.contains("://") {
return Self::parse(path);
}
if path.is_empty() {
return Ok(self.clone());
}
let mut base = self.raw.clone();
let base_has_trailing_slash = base.ends_with('/');
let path_has_leading_slash = path.starts_with('/');
if base_has_trailing_slash && path_has_leading_slash {
base.pop();
} else if !base_has_trailing_slash && !path_has_leading_slash {
base.push('/');
}
base.push_str(path);
Self::parse(base)
}
pub fn as_str(&self) -> &str {
&self.raw
}
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn authority(&self) -> &str {
&self.authority
}
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> Option<u16> {
self.port
}
pub fn effective_port(&self) -> u16 {
self.port.unwrap_or_else(|| match self.scheme.as_str() {
"http" | "ws" => 80,
"https" | "wss" => 443,
_ => 0,
})
}
pub fn path_and_query(&self) -> &str {
&self.path_and_query
}
pub fn with_query_pairs<I, K, V>(&self, pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
let mut raw = self.raw.clone();
let mut path_and_query = self.path_and_query.clone();
let separator = if path_and_query.contains('?') {
'&'
} else {
'?'
};
let mut first = true;
for (key, value) in pairs {
let prefix = if first { separator } else { '&' };
first = false;
let encoded = format!(
"{prefix}{}={}",
percent_encode(key.as_ref()),
percent_encode(value.as_ref())
);
raw.push_str(&encoded);
path_and_query.push_str(&encoded);
}
Self {
raw,
scheme: self.scheme.clone(),
authority: self.authority.clone(),
host: self.host.clone(),
port: self.port,
path_and_query,
}
}
}
impl fmt::Display for Url {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
fn parse_authority(authority: &str) -> Result<(String, Option<u16>)> {
if let Some((host, port)) = authority.rsplit_once(':') {
if host.is_empty() {
return Err(Error::new(ErrorKind::InvalidUrl, "url host is empty"));
}
if port.is_empty() {
return Err(Error::new(ErrorKind::InvalidUrl, "url port is empty"));
}
if port.bytes().all(|b| b.is_ascii_digit()) {
let port = port
.parse()
.map_err(|_| Error::new(ErrorKind::InvalidUrl, format!("invalid port: {port}")))?;
return Ok((host.to_owned(), Some(port)));
}
}
Ok((authority.to_owned(), None))
}
fn percent_encode(input: &str) -> String {
let mut encoded = String::new();
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
b' ' => encoded.push_str("%20"),
_ => encoded.push_str(&format!("%{:02X}", byte)),
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::Url;
#[test]
fn joins_base_url_and_relative_path() {
let base = Url::parse("https://api.example.com/v1").unwrap();
let joined = base.join("/users").unwrap();
assert_eq!(joined.as_str(), "https://api.example.com/v1/users");
}
#[test]
fn parses_scheme_host_and_port() {
let url = Url::parse("http://localhost:8080/path?q=1").unwrap();
assert_eq!(url.scheme(), "http");
assert_eq!(url.host(), "localhost");
assert_eq!(url.port(), Some(8080));
assert_eq!(url.path_and_query(), "/path?q=1");
}
#[test]
fn appends_query_pairs() {
let url = Url::parse("http://localhost/path").unwrap();
let url = url.with_query_pairs([("q", "hello world"), ("page", "1")]);
assert_eq!(url.as_str(), "http://localhost/path?q=hello%20world&page=1");
}
}