Skip to main content

http_url/
builder.rs

1use core::str::FromStr;
2
3use alloc::format;
4use alloc::string::String;
5use alloc::string::ToString;
6use alloc::vec::Vec;
7
8use url::Url;
9
10use crate::HttpUrl;
11use crate::error::{HttpUrlError, Result};
12use crate::scheme::Scheme;
13use crate::util::percent_decode;
14
15/// Builder for constructing HTTP/HTTPS URLs programmatically.
16///
17/// `HttpUrlBuilder` is the focus of this crate. It accumulates URL components
18/// as **decoded** Rust strings and, on [`build`][Self::build] / [`build_url`][Self::build_url],
19/// hands them to the `url` crate, which performs all percent-encoding,
20/// normalization and validation according to the WHATWG URL Standard.
21///
22/// # Example
23///
24/// ```
25/// use http_url::{HttpUrl, Scheme};
26///
27/// let url = HttpUrl::builder()
28///     .scheme(Scheme::Https)
29///     .host("example.com")
30///     .add_path_segment("api")
31///     .add_path_segment("v1")
32///     .add_query_parameter("key", "value")
33///     .fragment("section")
34///     .build()
35///     .unwrap();
36///
37/// assert_eq!(url.to_string(), "https://example.com/api/v1?key=value#section");
38/// ```
39#[derive(Debug, Clone)]
40pub struct HttpUrlBuilder {
41    /// The URL scheme, or `None` if not yet set.
42    scheme: Option<Scheme>,
43    /// Username for basic authentication.
44    username: String,
45    /// Password for basic authentication.
46    password: String,
47    /// The host, or `None` if not yet set.
48    host: Option<String>,
49    /// The port, or `None` to use the scheme's default.
50    port: Option<u16>,
51    /// Decoded path segments.
52    path_segments: Vec<String>,
53    /// Decoded query parameter pairs.
54    query_pairs: Vec<(String, String)>,
55    /// The fragment (decoded), or `None`.
56    fragment: Option<String>,
57}
58
59impl HttpUrlBuilder {
60    /// Create a new empty builder.
61    pub fn new() -> Self {
62        Self {
63            scheme: None,
64            username: String::new(),
65            password: String::new(),
66            host: None,
67            port: None,
68            path_segments: Vec::new(),
69            query_pairs: Vec::new(),
70            fragment: None,
71        }
72    }
73
74    /// Create a builder pre-populated from an existing [`url::Url`].
75    ///
76    /// The URL must have an `http` or `https` scheme. The returned builder is
77    /// ready to be modified and rebuilt via [`build`][Self::build] or
78    /// [`build_url`][Self::build_url].
79    ///
80    /// # Example
81    ///
82    /// ```
83    /// use http_url::HttpUrlBuilder;
84    ///
85    /// let u = url::Url::parse("https://example.com/a/b?q=1").unwrap();
86    /// let url = HttpUrlBuilder::from_url(u)
87    ///     .unwrap()
88    ///     .add_path_segment("c")
89    ///     .build()
90    ///     .unwrap();
91    /// assert_eq!(url.as_url().path(), "/a/b/c");
92    /// ```
93    pub fn from_url(url: Url) -> Result<Self> {
94        let scheme = Scheme::from_str(url.scheme())?;
95        Ok(Self {
96            scheme: Some(scheme),
97            username: url.username().to_string(),
98            password: url.password().unwrap_or("").to_string(),
99            host: Some(url.host_str().unwrap_or("").to_string()),
100            port: url.port(),
101            path_segments: url
102                .path_segments()
103                .map(|segs| segs.map(percent_decode).collect::<Vec<_>>())
104                .unwrap_or_default(),
105            query_pairs: url
106                .query_pairs()
107                .map(|(k, v)| (k.into_owned(), v.into_owned()))
108                .collect(),
109            fragment: url.fragment().map(percent_decode),
110        })
111    }
112
113    /// Create a builder pre-populated by parsing a URL string.
114    ///
115    /// Equivalent to [`HttpUrlBuilder::from_url`] applied to the result of
116    /// `url::Url::parse`.
117    pub fn parse(url: &str) -> Result<Self> {
118        Self::from_url(Url::parse(url)?)
119    }
120
121    /// Set the scheme.
122    pub fn scheme(mut self, scheme: Scheme) -> Self {
123        self.scheme = Some(scheme);
124        self
125    }
126
127    /// Set the username for basic auth.
128    pub fn username(mut self, username: &str) -> Self {
129        self.username = username.to_string();
130        self
131    }
132
133    /// Set the password for basic auth.
134    pub fn password(mut self, password: &str) -> Self {
135        self.password = password.to_string();
136        self
137    }
138
139    /// Set the host (domain name or IP address literal, including bracketed
140    /// IPv6 such as `[::1]`).
141    pub fn host(mut self, host: &str) -> Self {
142        self.host = Some(host.to_string());
143        self
144    }
145
146    /// Set the port. Use [`remove_port`][Self::remove_port] to revert to the
147    /// scheme's default.
148    pub fn port(mut self, port: u16) -> Self {
149        self.port = Some(port);
150        self
151    }
152
153    /// Revert to the scheme's default port.
154    pub fn remove_port(mut self) -> Self {
155        self.port = None;
156        self
157    }
158
159    /// Add a single path segment (decoded form; it is percent-encoded on
160    /// build).
161    pub fn add_path_segment(mut self, segment: &str) -> Self {
162        self.path_segments.push(segment.to_string());
163        self
164    }
165
166    /// Add multiple path segments at once (decoded form).
167    pub fn add_path_segments(mut self, segments: &[&str]) -> Self {
168        for seg in segments {
169            self.path_segments.push(seg.to_string());
170        }
171        self
172    }
173
174    /// Replace the entire path with the segments of `path` (decoded form).
175    ///
176    /// `path` is split on `/`; a leading `/` is ignored and empty segments are
177    /// dropped. Each segment is stored as-is (decoded) and re-encoded on
178    /// build. No percent-decoding is applied to the input — pass a decoded
179    /// path.
180    pub fn set_path(mut self, path: &str) -> Self {
181        self.path_segments.clear();
182        for seg in path.trim_start_matches('/').split('/') {
183            if !seg.is_empty() {
184                self.path_segments.push(seg.to_string());
185            }
186        }
187        self
188    }
189
190    /// Add a query parameter (name and value in decoded form). Both are
191    /// percent-encoded on build using `application/x-www-form-urlencoded`
192    /// semantics (space becomes `+`).
193    pub fn add_query_parameter(mut self, name: &str, value: &str) -> Self {
194        self.query_pairs.push((name.to_string(), value.to_string()));
195        self
196    }
197
198    /// Remove all query parameters with the given name.
199    pub fn remove_query_parameter(mut self, name: &str) -> Self {
200        self.query_pairs.retain(|(n, _)| n != name);
201        self
202    }
203
204    /// Clear all query parameters.
205    pub fn clear_query_parameters(mut self) -> Self {
206        self.query_pairs.clear();
207        self
208    }
209
210    /// Set the fragment (decoded form).
211    pub fn fragment(mut self, fragment: &str) -> Self {
212        self.fragment = Some(fragment.to_string());
213        self
214    }
215
216    /// Remove the fragment.
217    pub fn remove_fragment(mut self) -> Self {
218        self.fragment = None;
219        self
220    }
221
222    /// Consume the builder and produce a [`url::Url`].
223    ///
224    /// The scheme is always `http` or `https` (it is a typed [`Scheme`] field),
225    /// so the result is always a valid HTTP(S) URL. Returns an error only if
226    /// required components (scheme, host) are missing or the `url` crate
227    /// rejects the assembled URL (e.g. an invalid host).
228    ///
229    /// Use [`build`][Self::build] instead if you want an [`HttpUrl`] wrapper.
230    pub fn build_url(self) -> Result<Url> {
231        let scheme = self
232            .scheme
233            .ok_or_else(|| HttpUrlError::BuilderValidation("scheme is required".to_string()))?;
234        let host = self
235            .host
236            .ok_or_else(|| HttpUrlError::BuilderValidation("host is required".to_string()))?;
237
238        // Assemble the base URL; `url::Url::parse` performs host validation,
239        // IDNA/punycode normalization, etc.
240        let mut url = Url::parse(&format!("{}://{}", scheme.as_str(), host))?;
241
242        // Authority additions.
243        if !self.username.is_empty() {
244            let _ = url.set_username(&self.username);
245        }
246        if !self.password.is_empty() {
247            let _ = url.set_password(Some(&self.password));
248        }
249        if let Some(port) = self.port {
250            let _ = url.set_port(Some(port));
251        }
252
253        // Path segments. Only overwrite when the builder carries segments, so
254        // a builder derived via `from_url` keeps the original path when the
255        // caller never touches it.
256        if !self.path_segments.is_empty() {
257            let mut segments = url.path_segments_mut().map_err(|_| {
258                HttpUrlError::BuilderValidation("URL cannot have a path".to_string())
259            })?;
260            segments.clear();
261            for seg in &self.path_segments {
262                segments.push(seg);
263            }
264        }
265
266        // Query parameters. Explicitly clear when empty so that a builder
267        // derived via `from_url` does not retain a stale query after the
268        // caller calls `clear_query_parameters`.
269        if !self.query_pairs.is_empty() {
270            let mut query = url.query_pairs_mut();
271            for (name, value) in &self.query_pairs {
272                query.append_pair(name, value);
273            }
274        } else {
275            url.set_query(None);
276        }
277
278        url.set_fragment(self.fragment.as_deref());
279
280        Ok(url)
281    }
282
283    /// Consume the builder and produce an [`HttpUrl`] — a newtype over
284    /// [`url::Url`] enforcing the http/https invariant.
285    ///
286    /// Equivalent to [`HttpUrl::from_url`] applied to [`build_url`][Self::build_url].
287    pub fn build(self) -> Result<HttpUrl> {
288        HttpUrl::from_url(self.build_url()?)
289    }
290}
291
292impl Default for HttpUrlBuilder {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298impl FromStr for HttpUrlBuilder {
299    type Err = HttpUrlError;
300
301    fn from_str(s: &str) -> Result<Self> {
302        Self::parse(s)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn builder_basic() {
312        let url = HttpUrl::builder()
313            .scheme(Scheme::Https)
314            .host("example.com")
315            .build()
316            .unwrap();
317        assert_eq!(url.to_string(), "https://example.com/");
318    }
319
320    #[test]
321    fn builder_full() {
322        let url = HttpUrl::builder()
323            .scheme(Scheme::Https)
324            .username("user")
325            .password("pass")
326            .host("example.com")
327            .port(8080)
328            .add_path_segment("api")
329            .add_path_segment("v1")
330            .add_query_parameter("key", "value")
331            .fragment("section")
332            .build()
333            .unwrap();
334        assert_eq!(
335            url.to_string(),
336            "https://user:pass@example.com:8080/api/v1?key=value#section"
337        );
338    }
339
340    #[test]
341    fn builder_path_segments() {
342        let url = HttpUrl::builder()
343            .scheme(Scheme::Http)
344            .host("example.com")
345            .add_path_segment("a")
346            .add_path_segment("b")
347            .add_path_segment("c")
348            .build()
349            .unwrap();
350        assert_eq!(url.as_url().path(), "/a/b/c");
351    }
352
353    #[test]
354    fn builder_path_encoding() {
355        let url = HttpUrl::builder()
356            .scheme(Scheme::Http)
357            .host("example.com")
358            .add_path_segment("hello world")
359            .build()
360            .unwrap();
361        assert_eq!(url.as_url().path(), "/hello%20world");
362    }
363
364    #[test]
365    fn builder_unicode_segment() {
366        let url = HttpUrl::builder()
367            .scheme(Scheme::Http)
368            .host("example.com")
369            .add_path_segment("中文")
370            .build()
371            .unwrap();
372        assert_eq!(url.as_url().path(), "/%E4%B8%AD%E6%96%87");
373    }
374
375    #[test]
376    fn builder_set_path() {
377        let url = HttpUrl::builder()
378            .scheme(Scheme::Http)
379            .host("example.com")
380            .set_path("/a/b/c")
381            .build()
382            .unwrap();
383        assert_eq!(url.as_url().path(), "/a/b/c");
384    }
385
386    #[test]
387    fn builder_add_path_segments() {
388        let url = HttpUrl::builder()
389            .scheme(Scheme::Http)
390            .host("example.com")
391            .add_path_segments(&["a", "b", "c"])
392            .build()
393            .unwrap();
394        assert_eq!(url.as_url().path(), "/a/b/c");
395    }
396
397    #[test]
398    fn builder_query_encoding() {
399        let url = HttpUrl::builder()
400            .scheme(Scheme::Http)
401            .host("example.com")
402            .add_query_parameter("name", "hello world")
403            .build()
404            .unwrap();
405        // form_urlencoded uses `+` for space.
406        assert_eq!(url.as_url().query(), Some("name=hello+world"));
407    }
408
409    #[test]
410    fn builder_clear_query_parameters() {
411        let url = HttpUrl::builder()
412            .scheme(Scheme::Http)
413            .host("example.com")
414            .add_query_parameter("a", "1")
415            .clear_query_parameters()
416            .build()
417            .unwrap();
418        assert_eq!(url.as_url().query(), None);
419    }
420
421    #[test]
422    fn builder_remove_query_parameter() {
423        let url = HttpUrl::builder()
424            .scheme(Scheme::Https)
425            .host("example.com")
426            .add_query_parameter("a", "1")
427            .add_query_parameter("b", "2")
428            .remove_query_parameter("a")
429            .build()
430            .unwrap();
431        assert_eq!(url.as_url().query_pairs().find(|(k, _)| k == "a"), None);
432        assert_eq!(
433            url.as_url()
434                .query_pairs()
435                .find(|(k, _)| k == "b")
436                .map(|(_, v)| v.into_owned()),
437            Some("2".to_string())
438        );
439    }
440
441    #[test]
442    fn builder_remove_fragment() {
443        let url = HttpUrl::builder()
444            .scheme(Scheme::Https)
445            .host("example.com")
446            .fragment("sec")
447            .remove_fragment()
448            .build()
449            .unwrap();
450        assert_eq!(url.as_url().fragment(), None);
451    }
452
453    #[test]
454    fn builder_remove_port() {
455        let url = HttpUrl::builder()
456            .scheme(Scheme::Http)
457            .host("example.com")
458            .port(8080)
459            .remove_port()
460            .build()
461            .unwrap();
462        assert_eq!(url.as_url().port(), None); // default port normalized away
463    }
464
465    #[test]
466    fn builder_missing_scheme() {
467        assert!(HttpUrl::builder().host("example.com").build().is_err());
468    }
469
470    #[test]
471    fn builder_missing_host() {
472        assert!(HttpUrl::builder().scheme(Scheme::Http).build().is_err());
473    }
474
475    #[test]
476    fn builder_ipv6_host() {
477        let url = HttpUrl::builder()
478            .scheme(Scheme::Http)
479            .host("[::1]")
480            .port(8080)
481            .build()
482            .unwrap();
483        assert_eq!(url.as_url().host_str(), Some("[::1]"));
484        assert_eq!(url.as_url().port(), Some(8080));
485    }
486
487    #[test]
488    fn builder_idn_normalization() {
489        // The url crate normalizes internationalized domain names.
490        let url = HttpUrl::builder()
491            .scheme(Scheme::Http)
492            .host("Bücher.de")
493            .build()
494            .unwrap();
495        assert_eq!(url.as_url().host_str(), Some("xn--bcher-kva.de"));
496    }
497
498    #[test]
499    fn builder_new_builder_roundtrip() {
500        let url = HttpUrl::parse("https://example.com/a/b?x=1&y=2#frag").unwrap();
501        let url2 = url.new_builder().build().unwrap();
502        assert_eq!(url, url2);
503    }
504
505    #[test]
506    fn builder_new_builder_modify() {
507        let url = HttpUrl::parse("https://example.com/a/b?x=1").unwrap();
508        let url2 = url
509            .new_builder()
510            .add_path_segment("c")
511            .add_query_parameter("y", "2")
512            .build()
513            .unwrap();
514        assert_eq!(url2.as_url().path(), "/a/b/c");
515        assert_eq!(
516            url2.as_url()
517                .query_pairs()
518                .find(|(k, _)| k == "x")
519                .map(|(_, v)| v.into_owned()),
520            Some("1".to_string())
521        );
522        assert_eq!(
523            url2.as_url()
524                .query_pairs()
525                .find(|(k, _)| k == "y")
526                .map(|(_, v)| v.into_owned()),
527            Some("2".to_string())
528        );
529    }
530
531    #[test]
532    fn builder_from_url() {
533        let u = url::Url::parse("https://example.com/a/b?q=1#frag").unwrap();
534        let url = HttpUrlBuilder::from_url(u.clone())
535            .unwrap()
536            .add_path_segment("c")
537            .build()
538            .unwrap();
539        assert_eq!(url.as_url().path(), "/a/b/c");
540        assert_eq!(
541            url.as_url()
542                .query_pairs()
543                .find(|(k, _)| k == "q")
544                .map(|(_, v)| v.into_owned()),
545            Some("1".to_string())
546        );
547        assert_eq!(url.as_url().fragment(), Some("frag"));
548    }
549
550    #[test]
551    fn builder_from_url_rejects_non_http() {
552        let u = url::Url::parse("ftp://example.com").unwrap();
553        assert!(HttpUrlBuilder::from_url(u).is_err());
554    }
555
556    #[test]
557    fn builder_parse() {
558        let b: HttpUrlBuilder = "https://example.com/a/b".parse().unwrap();
559        let url = b.add_path_segment("c").build().unwrap();
560        assert_eq!(url.as_url().path(), "/a/b/c");
561    }
562
563    #[test]
564    fn builder_build_url() {
565        let url = HttpUrl::builder()
566            .scheme(Scheme::Https)
567            .host("example.com")
568            .add_path_segment("api")
569            .add_query_parameter("k", "v")
570            .fragment("sec")
571            .build_url()
572            .unwrap();
573        assert_eq!(url.as_str(), "https://example.com/api?k=v#sec");
574    }
575
576    #[test]
577    fn builder_build_url_missing_scheme() {
578        assert!(HttpUrl::builder().host("example.com").build_url().is_err());
579    }
580
581    #[test]
582    fn builder_parse_rejects_non_http() {
583        assert!(HttpUrlBuilder::parse("ftp://example.com").is_err());
584    }
585}