Skip to main content

http_url/
http_url.rs

1use alloc::format;
2use alloc::string::String;
3
4use core::convert::TryFrom;
5use core::fmt;
6use core::str::FromStr;
7
8use crate::builder::HttpUrlBuilder;
9use crate::error::{HttpUrlError, Result};
10use crate::scheme::Scheme;
11
12/// An immutable HTTP or HTTPS URL.
13///
14/// `HttpUrl` is a thin newtype around [`url::Url`] that enforces the
15/// "scheme is `http` or `https`" invariant at construction time. Parsing,
16/// percent-encoding, normalization and all component access are provided by
17/// the `url` crate — reach them through [`HttpUrl::as_url`] or
18/// [`HttpUrl::into_url`]. This crate focuses on the [`HttpUrlBuilder`] API
19/// for constructing URLs programmatically, plus a typed [`Scheme`] accessor
20/// that reflects the http/https invariant.
21///
22/// # Example
23///
24/// ```
25/// use http_url::HttpUrl;
26///
27/// let url = HttpUrl::parse("https://example.com/path?a=1#frag").unwrap();
28/// assert_eq!(url.scheme(), "https");
29/// assert_eq!(url.as_url().host_str(), Some("example.com"));
30/// assert_eq!(url.as_url().path(), "/path");
31/// assert_eq!(url.as_url().query(), Some("a=1"));
32/// assert_eq!(url.as_url().fragment(), Some("frag"));
33/// ```
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct HttpUrl(url::Url);
36
37impl HttpUrl {
38    // ── construction ───────────────────────────────────────────────
39
40    /// Parse a URL string into an `HttpUrl`.
41    ///
42    /// The URL must have an `http://` or `https://` scheme; all other parsing
43    /// behavior follows the WHATWG URL Standard as implemented by the `url`
44    /// crate.
45    pub fn parse(url: &str) -> Result<Self> {
46        Self::from_url(url::Url::parse(url)?)
47    }
48
49    /// Wrap an existing [`url::Url`], returning an error if its scheme is not
50    /// `http` or `https`.
51    pub fn from_url(url: url::Url) -> Result<Self> {
52        // `Scheme::from_str` validates the scheme; the `Scheme` value itself
53        // is not needed here since it is recoverable from `url.scheme()`.
54        let _ = Scheme::from_str(url.scheme())?;
55        Ok(Self(url))
56    }
57
58    /// Borrow the underlying [`url::Url`], through which all URL components
59    /// (host, path, query, fragment, …) are accessible.
60    pub fn as_url(&self) -> &url::Url {
61        &self.0
62    }
63
64    /// Consume this `HttpUrl` and return the underlying [`url::Url`].
65    pub fn into_url(self) -> url::Url {
66        self.0
67    }
68
69    // ── builder ────────────────────────────────────────────────────
70
71    /// Create a new [`HttpUrlBuilder`].
72    pub fn builder() -> HttpUrlBuilder {
73        HttpUrlBuilder::new()
74    }
75
76    /// Create a builder pre-populated with this URL's components, so it can be
77    /// modified and rebuilt.
78    ///
79    /// # Example
80    ///
81    /// ```
82    /// use http_url::HttpUrl;
83    ///
84    /// let url = HttpUrl::parse("https://example.com/a/b?q=1").unwrap();
85    /// let url2 = url.new_builder()
86    ///     .add_path_segment("c")
87    ///     .build()
88    ///     .unwrap();
89    /// assert_eq!(url2.as_url().path(), "/a/b/c");
90    /// ```
91    pub fn new_builder(&self) -> HttpUrlBuilder {
92        // The scheme invariant guarantees `from_url` cannot fail here.
93        HttpUrlBuilder::from_url(self.0.clone())
94            .expect("invariant: HttpUrl scheme is always http or https")
95    }
96
97    // ── typed accessor ─────────────────────────────────────────────
98
99    /// Returns the URL scheme as a typed [`Scheme`].
100    ///
101    /// This is the typed mirror of [`url::Url::scheme`]; for a valid
102    /// `HttpUrl` it is always `http` or `https`.
103    pub fn scheme(&self) -> Scheme {
104        // The invariant holds by construction, so `from_str` cannot fail.
105        Scheme::from_str(self.0.scheme())
106            .expect("invariant: HttpUrl scheme is always http or https")
107    }
108
109    // ── convenience helpers ────────────────────────────────────────
110
111    /// Compute the top private domain (eTLD+1) of this URL's host.
112    ///
113    /// Returns `None` if the host is an IP address or does not have enough
114    /// domain labels. This is a simplified implementation that assumes the
115    /// last two labels form the private domain; a full implementation would
116    /// consult the Public Suffix List.
117    pub fn top_private_domain(&self) -> Option<String> {
118        let host = self.0.host_str()?;
119        // Reject IP-address hosts (no alphabetic characters).
120        if !host.bytes().any(|b| b.is_ascii_alphabetic()) {
121            return None;
122        }
123        // Take the last two dot-separated labels.
124        let (rest, last) = host.rsplit_once('.')?;
125        let (_, second_last) = rest.rsplit_once('.').unwrap_or(("", rest));
126        Some(format!("{}.{}", second_last, last))
127    }
128}
129
130impl fmt::Display for HttpUrl {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        f.write_str(self.0.as_str())
133    }
134}
135
136impl FromStr for HttpUrl {
137    type Err = HttpUrlError;
138
139    fn from_str(s: &str) -> Result<Self> {
140        Self::parse(s)
141    }
142}
143
144// ── interop with url::Url ─────────────────────────────────────────
145
146impl From<HttpUrl> for url::Url {
147    fn from(http: HttpUrl) -> Self {
148        http.into_url()
149    }
150}
151
152impl TryFrom<url::Url> for HttpUrl {
153    type Error = HttpUrlError;
154
155    fn try_from(url: url::Url) -> Result<Self> {
156        Self::from_url(url)
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use alloc::string::ToString;
164    use alloc::vec;
165    use alloc::vec::Vec;
166
167    #[test]
168    fn parse_simple() {
169        let url = HttpUrl::parse("http://example.com").unwrap();
170        assert_eq!(url.scheme(), Scheme::Http);
171        assert_eq!(url.as_url().host_str(), Some("example.com"));
172        assert_eq!(url.as_url().port(), None); // default port not stored
173        assert_eq!(url.as_url().path(), "/");
174    }
175
176    #[test]
177    fn parse_https() {
178        let url = HttpUrl::parse("https://example.com/path").unwrap();
179        assert_eq!(url.scheme(), Scheme::Https);
180        assert_eq!(url.as_url().port(), None);
181        assert_eq!(url.as_url().path(), "/path");
182    }
183
184    #[test]
185    fn parse_explicit_port() {
186        let url = HttpUrl::parse("http://example.com:8080/").unwrap();
187        assert_eq!(url.as_url().port(), Some(8080));
188    }
189
190    #[test]
191    fn parse_default_port_omitted() {
192        // The `url` crate normalizes the default port away.
193        let url = HttpUrl::parse("http://example.com:80/").unwrap();
194        assert_eq!(url.as_url().port(), None);
195    }
196
197    #[test]
198    fn parse_userinfo() {
199        let url = HttpUrl::parse("http://user:pass@example.com/").unwrap();
200        assert_eq!(url.as_url().username(), "user");
201        assert_eq!(url.as_url().password(), Some("pass"));
202    }
203
204    #[test]
205    fn parse_username_only() {
206        let url = HttpUrl::parse("http://user@example.com/").unwrap();
207        assert_eq!(url.as_url().username(), "user");
208        assert_eq!(url.as_url().password(), None);
209    }
210
211    #[test]
212    fn parse_query() {
213        let url = HttpUrl::parse("http://example.com/?a=1&b=2").unwrap();
214        let pairs: Vec<(String, String)> = url
215            .as_url()
216            .query_pairs()
217            .map(|(k, v)| (k.into_owned(), v.into_owned()))
218            .collect();
219        assert_eq!(
220            pairs,
221            vec![
222                ("a".to_string(), "1".to_string()),
223                ("b".to_string(), "2".to_string())
224            ]
225        );
226    }
227
228    #[test]
229    fn parse_fragment() {
230        let url = HttpUrl::parse("http://example.com/#section").unwrap();
231        assert_eq!(url.as_url().fragment(), Some("section"));
232    }
233
234    #[test]
235    fn parse_encoded_path() {
236        let url = HttpUrl::parse("http://example.com/hello%20world").unwrap();
237        assert_eq!(url.as_url().path(), "/hello%20world");
238    }
239
240    #[test]
241    fn parse_ipv6() {
242        let url = HttpUrl::parse("http://[::1]:8080/path").unwrap();
243        assert_eq!(url.as_url().host_str(), Some("[::1]"));
244        assert_eq!(url.as_url().port(), Some(8080));
245    }
246
247    #[test]
248    fn parse_rejects_empty() {
249        assert!(HttpUrl::parse("").is_err());
250    }
251
252    #[test]
253    fn parse_rejects_other_schemes() {
254        assert!(HttpUrl::parse("ftp://example.com").is_err());
255        assert!(HttpUrl::parse("file:///etc/passwd").is_err());
256    }
257
258    #[test]
259    fn parse_rejects_missing_host() {
260        // `http://` with no host is invalid per the URL Standard.
261        assert!(HttpUrl::parse("http://").is_err());
262    }
263
264    #[test]
265    fn from_str_roundtrip() {
266        let inputs = [
267            "http://example.com/",
268            "https://example.com/path/to?q=1&r=2#frag",
269            "http://user:pass@host.com:8080/a/b/c",
270            "http://[::1]:9090/path",
271            "http://example.com/hello%20world",
272        ];
273        for input in &inputs {
274            let url: HttpUrl = input.parse().unwrap();
275            assert_eq!(url.to_string(), *input);
276        }
277    }
278
279    #[test]
280    fn display_matches_url_string() {
281        let url = HttpUrl::parse("https://example.com/a?b=2#c").unwrap();
282        assert_eq!(url.to_string(), url.as_url().as_str());
283    }
284
285    // ── relative resolution (via url::Url::join) ─────────────────
286
287    #[test]
288    fn resolve_absolute() {
289        let base = HttpUrl::parse("http://example.com/a/b").unwrap();
290        let resolved =
291            HttpUrl::from_url(base.as_url().join("http://other.com/c").unwrap()).unwrap();
292        assert_eq!(resolved.as_url().host_str(), Some("other.com"));
293        assert_eq!(resolved.as_url().path(), "/c");
294    }
295
296    #[test]
297    fn resolve_relative() {
298        let base = HttpUrl::parse("http://example.com/a/b").unwrap();
299        let resolved = HttpUrl::from_url(base.as_url().join("c").unwrap()).unwrap();
300        assert_eq!(resolved.as_url().path(), "/a/c");
301    }
302
303    #[test]
304    fn resolve_absolute_path() {
305        let base = HttpUrl::parse("http://example.com/a/b").unwrap();
306        let resolved = HttpUrl::from_url(base.as_url().join("/c/d").unwrap()).unwrap();
307        assert_eq!(resolved.as_url().path(), "/c/d");
308    }
309
310    #[test]
311    fn resolve_protocol_relative() {
312        let base = HttpUrl::parse("http://example.com/a/b").unwrap();
313        let resolved = HttpUrl::from_url(base.as_url().join("//other.com/c").unwrap()).unwrap();
314        assert_eq!(resolved.scheme(), Scheme::Http);
315        assert_eq!(resolved.as_url().host_str(), Some("other.com"));
316        assert_eq!(resolved.as_url().path(), "/c");
317    }
318
319    #[test]
320    fn resolve_query_only() {
321        let base = HttpUrl::parse("http://example.com/a/b?old=1").unwrap();
322        let resolved = HttpUrl::from_url(base.as_url().join("?new=2").unwrap()).unwrap();
323        assert_eq!(
324            resolved
325                .as_url()
326                .query_pairs()
327                .next()
328                .map(|(k, v)| (k.into_owned(), v.into_owned())),
329            Some(("new".to_string(), "2".to_string()))
330        );
331    }
332
333    #[test]
334    fn resolve_fragment_only() {
335        let base = HttpUrl::parse("http://example.com/a/b#old").unwrap();
336        let resolved = HttpUrl::from_url(base.as_url().join("#new").unwrap()).unwrap();
337        assert_eq!(resolved.as_url().fragment(), Some("new"));
338    }
339
340    // ── domain helpers ─────────────────────────────────────────────
341
342    #[test]
343    fn top_private_domain() {
344        let url = HttpUrl::parse("http://www.example.com/path").unwrap();
345        assert_eq!(url.top_private_domain(), Some("example.com".to_string()));
346    }
347
348    #[test]
349    fn top_private_domain_ip_is_none() {
350        let url = HttpUrl::parse("http://192.168.1.1/").unwrap();
351        assert_eq!(url.top_private_domain(), None);
352    }
353
354    // ── interop with url::Url ──────────────────────────────────────
355
356    #[test]
357    fn from_url_rejects_non_http() {
358        let u = url::Url::parse("ftp://example.com").unwrap();
359        assert!(HttpUrl::try_from(u).is_err());
360    }
361
362    #[test]
363    fn url_roundtrip() {
364        let u = url::Url::parse("https://example.com/path?q=1").unwrap();
365        let http = HttpUrl::try_from(u.clone()).unwrap();
366        assert_eq!(http.as_url(), &u);
367        let back: url::Url = http.into();
368        assert_eq!(back, u);
369    }
370
371    #[test]
372    fn new_builder_rebuilds_equal() {
373        let url = HttpUrl::parse("http://example.com/path?q=1#frag").unwrap();
374        let url2 = url.new_builder().build().unwrap();
375        assert_eq!(url, url2);
376    }
377}