Skip to main content

sim_lib_net_core/
url.rs

1//! URL parsing into structural components.
2
3use crate::NetError;
4
5/// The structural components of an absolute URL.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct UrlParts {
8    /// Lowercase-preserving scheme exactly as written (e.g. `http`, `https`).
9    pub scheme: String,
10    /// Host without port.
11    pub host: String,
12    /// Port: the explicit port if present, else the scheme default
13    /// (`http` -> 80, `https` -> 443).
14    pub port: u16,
15    /// Request path, defaulting to `/` when the URL has no path component.
16    /// A non-empty path has any trailing slash trimmed (except the lone `/`).
17    pub path: String,
18}
19
20/// Parse a `scheme://host[:port][/path]` URL.
21///
22/// Extracted from `sim-lib-agent-runner-http`'s `parse_url`. Differences from
23/// that internal helper, which are intentional for a shared primitive:
24///
25/// * Default ports are resolved here for `http` (80) and `https` (443); any
26///   other scheme is rejected with [`NetError::UnsupportedScheme`] when no
27///   explicit port is given. (The client only used `http`/`https`.)
28/// * The path defaults to `/` instead of the empty string when absent, so the
29///   result is a usable request target on its own.
30pub fn parse_url(url: &str) -> Result<UrlParts, NetError> {
31    let (scheme, rest) = url
32        .split_once("://")
33        .ok_or_else(|| NetError::MalformedUrl(url.to_owned()))?;
34
35    let (host_port, path) = match rest.split_once('/') {
36        Some((host_port, suffix)) => {
37            let trimmed = suffix.trim_end_matches('/');
38            (host_port, format!("/{trimmed}"))
39        }
40        None => (rest, "/".to_owned()),
41    };
42
43    let (host, port) = match host_port.rsplit_once(':') {
44        Some((host, port)) => (
45            host.to_owned(),
46            port.parse::<u16>()
47                .map_err(|_| NetError::InvalidPort(url.to_owned()))?,
48        ),
49        None => (host_port.to_owned(), default_port_for_scheme(scheme, url)?),
50    };
51
52    if host.is_empty() {
53        return Err(NetError::MalformedUrl(url.to_owned()));
54    }
55
56    Ok(UrlParts {
57        scheme: scheme.to_owned(),
58        host,
59        port,
60        path,
61    })
62}
63
64/// Parse a URL and require a specific `scheme`, applying `default_path` when the
65/// URL carries no path component.
66///
67/// A policy-free convenience over [`parse_url`] for transport callers that know
68/// which scheme they speak and want a usable request target:
69///
70/// * The scheme must equal `scheme` exactly, else
71///   [`NetError::UnexpectedScheme`] is returned. (Callers still get default-port
72///   resolution for `http`/`https` from [`parse_url`].)
73/// * When the URL had no path, [`parse_url`] yields `/`; `parse_url_for_scheme`
74///   substitutes `default_path` in that case, so a bare `scheme://host` becomes
75///   a request target at the caller's default endpoint.
76pub fn parse_url_for_scheme(
77    url: &str,
78    scheme: &str,
79    default_path: &str,
80) -> Result<UrlParts, NetError> {
81    let mut parts = parse_url(url)?;
82    if parts.scheme != scheme {
83        return Err(NetError::UnexpectedScheme {
84            expected: scheme.to_owned(),
85            found: parts.scheme,
86        });
87    }
88    if parts.path == "/" {
89        parts.path = default_path.to_owned();
90    }
91    Ok(parts)
92}
93
94fn default_port_for_scheme(scheme: &str, url: &str) -> Result<u16, NetError> {
95    match scheme {
96        "http" => Ok(80),
97        "https" => Ok(443),
98        _ => Err(NetError::UnsupportedScheme(format!("{scheme} in {url}"))),
99    }
100}