1use std::borrow::Cow;
2use std::collections::HashMap;
3
4use http::Method;
5use indexmap::IndexMap;
6use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
7use url::Url;
8
9use crate::error::Error;
10use crate::Result;
11
12const PATH_PARAM_ENCODE: &AsciiSet = &NON_ALPHANUMERIC
14 .remove(b'-')
15 .remove(b'_')
16 .remove(b'.')
17 .remove(b'~');
18
19#[derive(Debug, Clone)]
21pub struct BuiltUrl {
22 pub url: Url,
23 pub method_override: Option<Method>,
24}
25
26pub fn build_url(
30 base: &Url,
31 path: &str,
32 params: &HashMap<String, String>,
33 query: &IndexMap<String, QueryValue>,
34) -> Result<BuiltUrl> {
35 if path.starts_with("http://") || path.starts_with("https://") {
36 let (path_only, method_override) = parse_method_modifier(path);
37 let resolved_path = substitute_params(path_only, params)?;
38 let mut url = Url::parse(&resolved_path).map_err(Error::InvalidBaseUrl)?;
39 apply_query(&mut url, query)?;
40 return Ok(BuiltUrl {
41 url,
42 method_override,
43 });
44 }
45
46 let (path_only, method_override) = parse_method_modifier(path);
47 let resolved_path = substitute_params(path_only, params)?;
48 let mut url = join_path(base, &resolved_path)?;
49 apply_query(&mut url, query)?;
50
51 Ok(BuiltUrl {
52 url,
53 method_override,
54 })
55}
56
57fn apply_query(url: &mut Url, query: &IndexMap<String, QueryValue>) -> Result<()> {
58 if query.is_empty() {
59 return Ok(());
60 }
61 let mut pairs = url::form_urlencoded::Serializer::new(String::new());
62 for (key, value) in query {
63 match value {
64 QueryValue::Scalar(v) => {
65 pairs.append_pair(key, v);
66 }
67 QueryValue::Array(values) => {
68 for v in values {
69 pairs.append_pair(key, v);
70 }
71 }
72 }
73 }
74 let query_string = pairs.finish();
75 url.set_query(Some(&query_string));
76 Ok(())
77}
78
79pub fn parse_method_modifier(path: &str) -> (&str, Option<Method>) {
81 if let Some(rest) = path.strip_prefix('@') {
82 if let Some((method, remainder)) = rest.split_once('/') {
83 let m = match method.to_ascii_lowercase().as_str() {
84 "get" => Some(Method::GET),
85 "post" => Some(Method::POST),
86 "put" => Some(Method::PUT),
87 "patch" => Some(Method::PATCH),
88 "delete" => Some(Method::DELETE),
89 "head" => Some(Method::HEAD),
90 _ => None,
91 };
92 if m.is_some() {
93 return (remainder.trim_start_matches('/'), m);
94 }
95 }
96 }
97 (path, None)
98}
99
100fn substitute_params(path: &str, params: &HashMap<String, String>) -> Result<String> {
101 let mut result = path.to_string();
102 for (key, value) in params {
103 let placeholder = format!(":{key}");
104 if !result.contains(&placeholder) {
105 continue;
106 }
107 let encoded: Cow<'_, str> = utf8_percent_encode(value, PATH_PARAM_ENCODE).into();
108 result = result.replace(&placeholder, encoded.as_ref());
109 }
110
111 if result.contains(':') {
112 for segment in result.split('/') {
113 if segment.starts_with(':') {
114 return Err(Error::Other(format!(
115 "missing path parameter for `{}`",
116 segment
117 )));
118 }
119 }
120 }
121
122 Ok(result)
123}
124
125fn join_path(base: &Url, path: &str) -> Result<Url> {
126 let path = path.trim_start_matches('/');
127 let base_str = base.as_str().trim_end_matches('/');
128 let joined = if path.is_empty() {
129 base_str.to_string()
130 } else {
131 format!("{base_str}/{path}")
132 };
133 Url::parse(&joined).map_err(Error::InvalidBaseUrl)
134}
135
136#[derive(Debug, Clone)]
138pub enum QueryValue {
139 Scalar(String),
141 Array(Vec<String>),
143}
144
145#[cfg(feature = "json")]
149pub fn serialize_to_query_map<T: serde::Serialize>(
150 value: &T,
151) -> Result<IndexMap<String, QueryValue>> {
152 let json = serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))?;
153 let mut map = IndexMap::new();
154 if let serde_json::Value::Object(obj) = json {
155 for (key, val) in obj {
156 if val.is_null() {
157 continue;
158 }
159 map.insert(key, QueryValue::from_serializable(&val)?);
160 }
161 }
162 Ok(map)
163}
164
165impl QueryValue {
166 #[cfg(feature = "json")]
168 pub fn from_serializable<T: serde::Serialize>(value: &T) -> Result<Self> {
169 match serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))? {
170 serde_json::Value::String(s) => Ok(Self::Scalar(s)),
171 serde_json::Value::Number(n) => Ok(Self::Scalar(n.to_string())),
172 serde_json::Value::Bool(b) => Ok(Self::Scalar(b.to_string())),
173 serde_json::Value::Array(arr) => {
174 let values: Vec<String> = arr
175 .into_iter()
176 .map(|v| match v {
177 serde_json::Value::String(s) => s,
178 other => other.to_string(),
179 })
180 .collect();
181 Ok(Self::Array(values))
182 }
183 other => Ok(Self::Scalar(other.to_string())),
184 }
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn base() -> Url {
193 Url::parse("https://api.example.com").unwrap()
194 }
195
196 #[test]
197 fn substitutes_colon_params() {
198 let mut params = HashMap::new();
199 params.insert("id".into(), "42".into());
200 let built = build_url(&base(), "/todos/:id", ¶ms, &IndexMap::new()).unwrap();
201 assert_eq!(built.url.as_str(), "https://api.example.com/todos/42");
202 }
203
204 #[test]
205 fn multiple_params() {
206 let mut params = HashMap::new();
207 params.insert("id".into(), "1".into());
208 params.insert("title".into(), "hello".into());
209 let built = build_url(&base(), "/post/:id/:title", ¶ms, &IndexMap::new()).unwrap();
210 assert_eq!(built.url.as_str(), "https://api.example.com/post/1/hello");
211 }
212
213 #[test]
214 fn encodes_special_characters_in_params() {
215 let mut params = HashMap::new();
216 params.insert("id".into(), "a/b".into());
217 let built = build_url(&base(), "/items/:id", ¶ms, &IndexMap::new()).unwrap();
218 assert!(built.url.path().contains("a%2Fb"));
219 }
220
221 #[test]
222 fn missing_param_errors() {
223 let err = build_url(&base(), "/todos/:id", &HashMap::new(), &IndexMap::new()).unwrap_err();
224 assert!(matches!(err, Error::Other(_)));
225 }
226
227 #[test]
228 fn query_scalar() {
229 let mut query = IndexMap::new();
230 query.insert("q".into(), QueryValue::Scalar("rust".into()));
231 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
232 assert_eq!(built.url.query(), Some("q=rust"));
233 }
234
235 #[test]
236 fn query_array() {
237 let mut query = IndexMap::new();
238 query.insert(
239 "tag".into(),
240 QueryValue::Array(vec!["a".into(), "b".into()]),
241 );
242 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
243 let q = built.url.query().unwrap();
244 assert!(q.contains("tag=a"));
245 assert!(q.contains("tag=b"));
246 }
247
248 #[test]
249 fn query_preserves_insertion_order() {
250 let mut query = IndexMap::new();
251 query.insert("z".into(), QueryValue::Scalar("1".into()));
252 query.insert("a".into(), QueryValue::Scalar("2".into()));
253 query.insert("m".into(), QueryValue::Scalar("3".into()));
254 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
255 assert_eq!(built.url.query(), Some("z=1&a=2&m=3"));
256 }
257
258 #[test]
259 fn method_modifier_put() {
260 let (path, method) = parse_method_modifier("@put/user");
261 assert_eq!(path, "user");
262 assert_eq!(method, Some(Method::PUT));
263 }
264
265 #[test]
266 fn method_modifier_in_build_url() {
267 let built = build_url(&base(), "@patch/items", &HashMap::new(), &IndexMap::new()).unwrap();
268 assert_eq!(built.url.path(), "/items");
269 assert_eq!(built.method_override, Some(Method::PATCH));
270 }
271
272 #[test]
273 fn absolute_url_ignores_base() {
274 let mut params = HashMap::new();
275 params.insert("id".into(), "5".into());
276 let built = build_url(
277 &base(),
278 "https://other.example.com/users/:id",
279 ¶ms,
280 &IndexMap::new(),
281 )
282 .unwrap();
283 assert_eq!(built.url.as_str(), "https://other.example.com/users/5");
284 }
285
286 #[test]
287 fn empty_path_uses_base() {
288 let built = build_url(&base(), "", &HashMap::new(), &IndexMap::new()).unwrap();
289 assert_eq!(built.url.as_str(), "https://api.example.com/");
290 }
291}
292
293#[cfg(test)]
294mod proptests {
295 use super::*;
296 use proptest::prelude::*;
297
298 proptest! {
299 #[test]
300 fn substitute_preserves_literal_segments(path in r"([a-z]+/)*[a-z]+") {
301 let mut params = HashMap::new();
302 params.insert("id".into(), "42".into());
303 let template = format!("/{path}/:id");
304 let built = build_url(
305 &Url::parse("https://api.example.com").unwrap(),
306 &template,
307 ¶ms,
308 &IndexMap::new(),
309 )
310 .unwrap();
311 prop_assert!(built.url.as_str().ends_with("/42"));
312 }
313 }
314}