1use percent_encoding::percent_decode_str;
2
3pub use ::url::*;
4
5pub trait UrlExt: Sized {
7 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#[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
56fn is_path_char(c: char) -> bool {
64 is_query_char(c) && !matches!(c, '/' | '?' | '^' | '`' | '{' | '}')
65}
66
67fn 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}