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 path_param_names(path: &str) -> Vec<String> {
28 crate::path_params::path_param_names(path)
29}
30
31pub fn build_url(
36 base: &Url,
37 path: &str,
38 params: &HashMap<String, String>,
39 query: &IndexMap<String, QueryValue>,
40) -> Result<BuiltUrl> {
41 if path.starts_with("http://") || path.starts_with("https://") {
42 let (path_only, method_override) = parse_method_modifier(path);
43 let (path_only, embedded_query) = split_embedded_query(path_only);
44 let resolved_path = substitute_params(path_only, params)?;
45 let mut url = Url::parse(&resolved_path).map_err(Error::InvalidBaseUrl)?;
46 let merged = merge_queries(embedded_query, query);
47 apply_query(&mut url, &merged)?;
48 return Ok(BuiltUrl {
49 url,
50 method_override,
51 });
52 }
53
54 let (path_only, method_override) = parse_method_modifier(path);
55 let (path_only, embedded_query) = split_embedded_query(path_only);
56 let resolved_path = substitute_params(path_only, params)?;
57 let mut url = join_path(base, &resolved_path)?;
58 let merged = merge_queries(embedded_query, query);
59 apply_query(&mut url, &merged)?;
60
61 Ok(BuiltUrl {
62 url,
63 method_override,
64 })
65}
66
67fn split_embedded_query(path: &str) -> (&str, IndexMap<String, QueryValue>) {
68 let Some((path_only, query_str)) = path.split_once('?') else {
69 return (path, IndexMap::new());
70 };
71 (path_only, parse_query_string(query_str))
72}
73
74fn parse_query_string(query_str: &str) -> IndexMap<String, QueryValue> {
75 let mut map = IndexMap::new();
76 for (key, value) in url::form_urlencoded::parse(query_str.as_bytes()) {
77 let key = key.into_owned();
78 let value = value.into_owned();
79 match map.get_mut(&key) {
80 None => {
81 map.insert(key, QueryValue::Scalar(value));
82 }
83 Some(QueryValue::Scalar(prev)) => {
84 let first = prev.clone();
85 map.insert(key, QueryValue::Array(vec![first, value]));
86 }
87 Some(QueryValue::Array(values)) => {
88 values.push(value);
89 }
90 }
91 }
92 map
93}
94
95fn merge_queries(
96 embedded: IndexMap<String, QueryValue>,
97 builder: &IndexMap<String, QueryValue>,
98) -> IndexMap<String, QueryValue> {
99 let mut merged = embedded;
100 for (key, value) in builder {
101 merged.insert(key.clone(), value.clone());
102 }
103 merged
104}
105
106fn apply_query(url: &mut Url, query: &IndexMap<String, QueryValue>) -> Result<()> {
107 if query.is_empty() {
108 return Ok(());
109 }
110 let mut pairs = url::form_urlencoded::Serializer::new(String::new());
111 for (key, value) in query {
112 match value {
113 QueryValue::Scalar(v) => {
114 pairs.append_pair(key, v);
115 }
116 QueryValue::Array(values) => {
117 for v in values {
118 pairs.append_pair(key, v);
119 }
120 }
121 }
122 }
123 let query_string = pairs.finish();
124 url.set_query(Some(&query_string));
125 Ok(())
126}
127
128pub fn parse_method_modifier(path: &str) -> (&str, Option<Method>) {
130 if let Some(rest) = path.strip_prefix('@') {
131 if let Some((method, remainder)) = rest.split_once('/') {
132 let m = match method.to_ascii_lowercase().as_str() {
133 "get" => Some(Method::GET),
134 "post" => Some(Method::POST),
135 "put" => Some(Method::PUT),
136 "patch" => Some(Method::PATCH),
137 "delete" => Some(Method::DELETE),
138 "head" => Some(Method::HEAD),
139 _ => None,
140 };
141 if m.is_some() {
142 return (remainder.trim_start_matches('/'), m);
143 }
144 }
145 }
146 (path, None)
147}
148
149fn substitute_params(path: &str, params: &HashMap<String, String>) -> Result<String> {
150 let mut result = path.to_string();
151 for key in path_param_names(path) {
152 let placeholder = format!(":{key}");
153 let Some(value) = params.get(&key) else {
154 return Err(Error::MissingPathParam(key));
155 };
156 let encoded: Cow<'_, str> = utf8_percent_encode(value, PATH_PARAM_ENCODE).into();
157 result = result.replace(&placeholder, encoded.as_ref());
158 }
159
160 if result.contains(':') {
161 for segment in result.split('/') {
162 if segment.starts_with(':') {
163 let name = segment.trim_start_matches(':');
164 return Err(Error::MissingPathParam(name.to_string()));
165 }
166 }
167 }
168
169 Ok(result)
170}
171
172fn join_path(base: &Url, path: &str) -> Result<Url> {
173 let path = path.trim_start_matches('/');
174 let base_str = base.as_str().trim_end_matches('/');
175 let joined = if path.is_empty() {
176 base_str.to_string()
177 } else {
178 format!("{base_str}/{path}")
179 };
180 Url::parse(&joined).map_err(Error::InvalidBaseUrl)
181}
182
183#[derive(Debug, Clone)]
185pub enum QueryValue {
186 Scalar(String),
188 Array(Vec<String>),
190}
191
192#[cfg(feature = "json")]
196pub fn serialize_to_query_map<T: serde::Serialize>(
197 value: &T,
198) -> Result<IndexMap<String, QueryValue>> {
199 let json = serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))?;
200 let mut map = IndexMap::new();
201 if let serde_json::Value::Object(obj) = json {
202 for (key, val) in obj {
203 if val.is_null() {
204 continue;
205 }
206 map.insert(key, QueryValue::from_serializable(&val)?);
207 }
208 }
209 Ok(map)
210}
211
212impl QueryValue {
213 #[cfg(feature = "json")]
215 pub fn from_serializable<T: serde::Serialize>(value: &T) -> Result<Self> {
216 match serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))? {
217 serde_json::Value::String(s) => Ok(Self::Scalar(s)),
218 serde_json::Value::Number(n) => Ok(Self::Scalar(n.to_string())),
219 serde_json::Value::Bool(b) => Ok(Self::Scalar(b.to_string())),
220 serde_json::Value::Array(arr) => {
221 let values: Vec<String> = arr
222 .into_iter()
223 .map(|v| match v {
224 serde_json::Value::String(s) => s,
225 other => other.to_string(),
226 })
227 .collect();
228 Ok(Self::Array(values))
229 }
230 other => Ok(Self::Scalar(other.to_string())),
231 }
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn base() -> Url {
240 Url::parse("https://api.example.com").unwrap()
241 }
242
243 #[test]
244 fn substitutes_colon_params() {
245 let mut params = HashMap::new();
246 params.insert("id".into(), "42".into());
247 let built = build_url(&base(), "/todos/:id", ¶ms, &IndexMap::new()).unwrap();
248 assert_eq!(built.url.as_str(), "https://api.example.com/todos/42");
249 }
250
251 #[test]
252 fn multiple_params() {
253 let mut params = HashMap::new();
254 params.insert("id".into(), "1".into());
255 params.insert("title".into(), "hello".into());
256 let built = build_url(&base(), "/post/:id/:title", ¶ms, &IndexMap::new()).unwrap();
257 assert_eq!(built.url.as_str(), "https://api.example.com/post/1/hello");
258 }
259
260 #[test]
261 fn encodes_special_characters_in_params() {
262 let mut params = HashMap::new();
263 params.insert("id".into(), "a/b".into());
264 let built = build_url(&base(), "/items/:id", ¶ms, &IndexMap::new()).unwrap();
265 assert!(built.url.path().contains("a%2Fb"));
266 }
267
268 #[test]
269 fn missing_param_errors() {
270 let err = build_url(&base(), "/todos/:id", &HashMap::new(), &IndexMap::new()).unwrap_err();
271 assert!(matches!(err, Error::MissingPathParam(_)));
272 }
273
274 #[test]
275 fn embedded_query_in_path_is_merged() {
276 let built = build_url(
277 &base(),
278 "/search?tag=rust",
279 &HashMap::new(),
280 &IndexMap::new(),
281 )
282 .unwrap();
283 assert_eq!(built.url.query(), Some("tag=rust"));
284 }
285
286 #[test]
287 fn builder_query_overrides_embedded_query() {
288 let mut query = IndexMap::new();
289 query.insert("tag".into(), QueryValue::Scalar("override".into()));
290 let built = build_url(&base(), "/search?tag=rust", &HashMap::new(), &query).unwrap();
291 assert_eq!(built.url.query(), Some("tag=override"));
292 }
293
294 #[test]
295 fn query_scalar() {
296 let mut query = IndexMap::new();
297 query.insert("q".into(), QueryValue::Scalar("rust".into()));
298 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
299 assert_eq!(built.url.query(), Some("q=rust"));
300 }
301
302 #[test]
303 fn query_array() {
304 let mut query = IndexMap::new();
305 query.insert(
306 "tag".into(),
307 QueryValue::Array(vec!["a".into(), "b".into()]),
308 );
309 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
310 let q = built.url.query().unwrap();
311 assert!(q.contains("tag=a"));
312 assert!(q.contains("tag=b"));
313 }
314
315 #[test]
316 fn query_preserves_insertion_order() {
317 let mut query = IndexMap::new();
318 query.insert("z".into(), QueryValue::Scalar("1".into()));
319 query.insert("a".into(), QueryValue::Scalar("2".into()));
320 query.insert("m".into(), QueryValue::Scalar("3".into()));
321 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
322 assert_eq!(built.url.query(), Some("z=1&a=2&m=3"));
323 }
324
325 #[test]
326 fn method_modifier_put() {
327 let (path, method) = parse_method_modifier("@put/user");
328 assert_eq!(path, "user");
329 assert_eq!(method, Some(Method::PUT));
330 }
331
332 #[test]
333 fn method_modifier_in_build_url() {
334 let built = build_url(&base(), "@patch/items", &HashMap::new(), &IndexMap::new()).unwrap();
335 assert_eq!(built.url.path(), "/items");
336 assert_eq!(built.method_override, Some(Method::PATCH));
337 }
338
339 #[test]
340 fn absolute_url_ignores_base() {
341 let mut params = HashMap::new();
342 params.insert("id".into(), "5".into());
343 let built = build_url(
344 &base(),
345 "https://other.example.com/users/:id",
346 ¶ms,
347 &IndexMap::new(),
348 )
349 .unwrap();
350 assert_eq!(built.url.as_str(), "https://other.example.com/users/5");
351 }
352
353 #[test]
354 fn empty_path_uses_base() {
355 let built = build_url(&base(), "", &HashMap::new(), &IndexMap::new()).unwrap();
356 assert_eq!(built.url.as_str(), "https://api.example.com/");
357 }
358}
359
360#[cfg(test)]
361mod proptests {
362 use super::*;
363 use proptest::prelude::*;
364
365 proptest! {
366 #[test]
367 fn substitute_preserves_literal_segments(path in r"([a-z]+/)*[a-z]+") {
368 let mut params = HashMap::new();
369 params.insert("id".into(), "42".into());
370 let template = format!("/{path}/:id");
371 let built = build_url(
372 &Url::parse("https://api.example.com").unwrap(),
373 &template,
374 ¶ms,
375 &IndexMap::new(),
376 )
377 .unwrap();
378 prop_assert!(built.url.as_str().ends_with("/42"));
379 }
380 }
381}