Skip to main content

ploidy_util/
url.rs

1use percent_encoding::percent_decode_str;
2
3pub use ::url::*;
4
5/// Extensions to [`Url`].
6pub trait UrlExt: Sized {
7    /// Returns this URL with path segments and query parameters from
8    /// `path_and_query` appended.
9    fn with_path_and_query(self, path_and_query: &str) -> Result<Self, PathAndQueryError>;
10}
11
12impl UrlExt for Url {
13    fn with_path_and_query(mut self, path_and_query: &str) -> Result<Self, PathAndQueryError> {
14        let path_and_query = path_and_query.strip_prefix('/').unwrap_or(path_and_query);
15        let (path, query) = path_and_query
16            .split_once('?')
17            .unwrap_or((path_and_query, ""));
18        if !path.is_empty() {
19            let mut segments = self
20                .path_segments_mut()
21                .map_err(|()| PathAndQueryError::UrlCannotBeABase)?;
22            segments.pop_if_empty();
23            for segment in path.split('/') {
24                if segment.is_empty() || !segment.chars().all(is_path_char) {
25                    Err(PathAndQueryError::BadPathChar)?;
26                }
27                segments.push(
28                    &percent_decode_str(segment)
29                        .decode_utf8()
30                        .map_err(|_| PathAndQueryError::BadPathChar)?,
31                );
32            }
33        }
34        if !query.is_empty() {
35            if !query.chars().all(is_query_char) {
36                Err(PathAndQueryError::BadQueryChar)?;
37            }
38            self.query_pairs_mut()
39                .extend_pairs(::url::form_urlencoded::parse(query.as_bytes()));
40        }
41        Ok(self)
42    }
43}
44
45/// An error returned when a path and query can't be parsed.
46#[derive(Clone, Copy, Debug, thiserror::Error)]
47pub enum PathAndQueryError {
48    #[error("URL can't be used as a base URL")]
49    UrlCannotBeABase,
50    #[error("URL path contains invalid character")]
51    BadPathChar,
52    #[error("URL query contains invalid character")]
53    BadQueryChar,
54}
55
56/// Returns whether `c` is allowed in a URL path segment per
57/// the WHATWG URL Standard's [path percent-encode set][set].
58///
59/// Matches `ploidy_core::parse::path`; duplicated here to avoid
60/// `ploidy-util` depending on `ploidy-core`.
61///
62/// [set]: https://url.spec.whatwg.org/#path-percent-encode-set
63fn is_path_char(c: char) -> bool {
64    is_query_char(c) && !matches!(c, '/' | '?' | '^' | '`' | '{' | '}')
65}
66
67/// Returns whether `c` is allowed in a URL query string per
68/// the WHATWG URL Standard's [query percent-encode set][set].
69/// Duplicated from `ploidy_core::parse::path`.
70///
71/// [set]: https://url.spec.whatwg.org/#query-percent-encode-set
72fn is_query_char(c: char) -> bool {
73    !matches!(
74        c,
75        '\x00'..='\x1f' | ('\x7f'..) | ' ' | '"' | '#' | '<' | '>'
76    )
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_appends_relative_path_and_query() {
85        let url = Url::parse("https://api.example.com/v1")
86            .unwrap()
87            .with_path_and_query("pets/list?limit=10")
88            .unwrap();
89        assert_eq!(
90            url.as_str(),
91            "https://api.example.com/v1/pets/list?limit=10"
92        );
93    }
94
95    #[test]
96    fn test_appends_absolute_path() {
97        let url = Url::parse("https://api.example.com/v1/")
98            .unwrap()
99            .with_path_and_query("/pets/list")
100            .unwrap();
101        assert_eq!(url.as_str(), "https://api.example.com/v1/pets/list");
102    }
103
104    #[test]
105    fn test_appends_query_only() {
106        let url = Url::parse("https://api.example.com/v1?beta=true")
107            .unwrap()
108            .with_path_and_query("?limit=10")
109            .unwrap();
110        assert_eq!(
111            url.as_str(),
112            "https://api.example.com/v1?beta=true&limit=10"
113        );
114    }
115
116    #[test]
117    fn test_decodes_path_segments_before_appending() {
118        let url = Url::parse("https://api.example.com/v1")
119            .unwrap()
120            .with_path_and_query("pets/%E6%9F%B4%20%E7%8A%AC")
121            .unwrap();
122        assert_eq!(
123            url.as_str(),
124            "https://api.example.com/v1/pets/%E6%9F%B4%20%E7%8A%AC"
125        );
126    }
127
128    #[test]
129    fn test_ignores_empty_query() {
130        let url = Url::parse("https://api.example.com/v1")
131            .unwrap()
132            .with_path_and_query("?")
133            .unwrap();
134        assert_eq!(url.as_str(), "https://api.example.com/v1");
135    }
136
137    #[test]
138    fn test_rejects_invalid_path_char() {
139        let url = Url::parse("https://api.example.com/v1").unwrap();
140
141        let err = url.with_path_and_query("pets/{id}");
142        assert!(err.is_err());
143    }
144
145    #[test]
146    fn test_rejects_empty_path_segment() {
147        let url = Url::parse("https://api.example.com/v1").unwrap();
148
149        let err = url.with_path_and_query("pets//list");
150        assert!(err.is_err());
151    }
152
153    #[test]
154    fn test_rejects_invalid_query_char() {
155        let url = Url::parse("https://api.example.com/v1").unwrap();
156
157        let err = url.with_path_and_query("pets?tag=dog#cat");
158        assert!(err.is_err());
159    }
160}