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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct HttpUrl(url::Url);
36
37impl HttpUrl {
38 pub fn parse(url: &str) -> Result<Self> {
46 Self::from_url(url::Url::parse(url)?)
47 }
48
49 pub fn from_url(url: url::Url) -> Result<Self> {
52 let _ = Scheme::from_str(url.scheme())?;
55 Ok(Self(url))
56 }
57
58 pub fn as_url(&self) -> &url::Url {
61 &self.0
62 }
63
64 pub fn into_url(self) -> url::Url {
66 self.0
67 }
68
69 pub fn builder() -> HttpUrlBuilder {
73 HttpUrlBuilder::new()
74 }
75
76 pub fn new_builder(&self) -> HttpUrlBuilder {
92 HttpUrlBuilder::from_url(self.0.clone())
94 .expect("invariant: HttpUrl scheme is always http or https")
95 }
96
97 pub fn scheme(&self) -> Scheme {
104 Scheme::from_str(self.0.scheme())
106 .expect("invariant: HttpUrl scheme is always http or https")
107 }
108
109 pub fn top_private_domain(&self) -> Option<String> {
118 let host = self.0.host_str()?;
119 if !host.bytes().any(|b| b.is_ascii_alphabetic()) {
121 return None;
122 }
123 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
144impl 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); 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 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 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 #[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 #[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 #[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}