ugi 0.2.1

Runtime-agnostic Rust request client with HTTP/1.1, HTTP/2, HTTP/3, H2C, WebSocket, SSE, and gRPC support
Documentation
use std::fmt;

use crate::error::{Error, ErrorKind, Result};

/// A parsed HTTP/HTTPS/WS/WSS URL.
///
/// Stores the raw string alongside the decomposed components so that all
/// accessor methods are allocation-free.  Use [`Url::parse`] to construct one.
#[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 {
    /// Parse a URL string.
    ///
    /// Returns an error if the input is empty, has no scheme, or has an empty
    /// authority.  Query strings, fragments, and percent-encoding in the path
    /// are passed through unchanged.
    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(),
        })
    }

    /// Resolve `path` relative to this URL.
    ///
    /// If `path` is an absolute URL (contains `://`) it is parsed directly.
    /// Otherwise it is appended to the base URL, inserting a `/` separator if
    /// necessary.
    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)
    }

    /// Return the full URL string.
    pub fn as_str(&self) -> &str {
        &self.raw
    }

    /// Return the URL scheme (e.g. `"https"`).
    pub fn scheme(&self) -> &str {
        &self.scheme
    }

    /// Return the authority component (host, optionally with port).
    pub fn authority(&self) -> &str {
        &self.authority
    }

    /// Return the host name or IP address.
    pub fn host(&self) -> &str {
        &self.host
    }

    /// Return the explicit port, if one was present in the URL.
    pub fn port(&self) -> Option<u16> {
        self.port
    }

    /// Return the port that should actually be used for connections.
    ///
    /// Falls back to scheme defaults: 80 for `http`/`ws`, 443 for `https`/`wss`.
    pub fn effective_port(&self) -> u16 {
        self.port.unwrap_or_else(|| match self.scheme.as_str() {
            "http" | "ws" => 80,
            "https" | "wss" => 443,
            _ => 0,
        })
    }

    /// Return the path and query string (e.g. `"/search?q=foo"`).
    pub fn path_and_query(&self) -> &str {
        &self.path_and_query
    }

    /// Return a new URL with the given query key-value pairs appended.
    ///
    /// Keys and values are percent-encoded.  If the URL already has a query
    /// string the pairs are appended with `&`; otherwise a `?` is used.
    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");
    }
}