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
31#[doc(hidden)]
33pub fn fuzz_build_url(path: &str) -> Result<BuiltUrl> {
34 build_url(
35 &Url::parse("https://api.example.com").map_err(Error::InvalidBaseUrl)?,
36 path,
37 &HashMap::new(),
38 &IndexMap::new(),
39 )
40}
41
42#[doc(hidden)]
44pub fn fuzz_parse_embedded_query(path: &str) -> Result<BuiltUrl> {
45 fuzz_build_url(path)
46}
47
48pub fn build_url(
53 base: &Url,
54 path: &str,
55 params: &HashMap<String, String>,
56 query: &IndexMap<String, QueryValue>,
57) -> Result<BuiltUrl> {
58 if path.starts_with("http://") || path.starts_with("https://") {
59 let (path_only, method_override) = parse_method_modifier(path);
60 let (path_only, embedded_query) = split_embedded_query(path_only);
61 let resolved_path = substitute_params(path_only, params)?;
62 let mut url = Url::parse(&resolved_path).map_err(Error::InvalidBaseUrl)?;
63 let merged = merge_queries(embedded_query, query);
64 apply_query(&mut url, &merged)?;
65 return Ok(BuiltUrl {
66 url,
67 method_override,
68 });
69 }
70
71 let (path_only, method_override) = parse_method_modifier(path);
72 let (path_only, embedded_query) = split_embedded_query(path_only);
73 let resolved_path = substitute_params(path_only, params)?;
74 let mut url = join_path(base, &resolved_path)?;
75 let merged = merge_queries(embedded_query, query);
76 apply_query(&mut url, &merged)?;
77
78 Ok(BuiltUrl {
79 url,
80 method_override,
81 })
82}
83
84fn split_embedded_query(path: &str) -> (&str, IndexMap<String, QueryValue>) {
85 let Some((path_only, query_str)) = path.split_once('?') else {
86 return (path, IndexMap::new());
87 };
88 (path_only, parse_query_string(query_str))
89}
90
91fn parse_query_string(query_str: &str) -> IndexMap<String, QueryValue> {
92 let mut map = IndexMap::new();
93 for (key, value) in url::form_urlencoded::parse(query_str.as_bytes()) {
94 let key = key.into_owned();
95 let value = value.into_owned();
96 match map.get_mut(&key) {
97 None => {
98 map.insert(key, QueryValue::Scalar(value));
99 }
100 Some(QueryValue::Scalar(prev)) => {
101 let first = prev.clone();
102 map.insert(key, QueryValue::Array(vec![first, value]));
103 }
104 Some(QueryValue::Array(values)) => {
105 values.push(value);
106 }
107 }
108 }
109 map
110}
111
112fn merge_queries(
113 embedded: IndexMap<String, QueryValue>,
114 builder: &IndexMap<String, QueryValue>,
115) -> IndexMap<String, QueryValue> {
116 let mut merged = embedded;
117 for (key, value) in builder {
118 merged.insert(key.clone(), value.clone());
119 }
120 merged
121}
122
123fn apply_query(url: &mut Url, query: &IndexMap<String, QueryValue>) -> Result<()> {
124 if query.is_empty() {
125 return Ok(());
126 }
127 let mut pairs = url::form_urlencoded::Serializer::new(String::new());
128 for (key, value) in query {
129 match value {
130 QueryValue::Scalar(v) => {
131 pairs.append_pair(key, v);
132 }
133 QueryValue::Array(values) => {
134 for v in values {
135 pairs.append_pair(key, v);
136 }
137 }
138 }
139 }
140 let query_string = pairs.finish();
141 url.set_query(Some(&query_string));
142 Ok(())
143}
144
145pub fn parse_method_modifier(path: &str) -> (&str, Option<Method>) {
147 if let Some(rest) = path.strip_prefix('@') {
148 if let Some((method, remainder)) = rest.split_once('/') {
149 let m = match method.to_ascii_lowercase().as_str() {
150 "get" => Some(Method::GET),
151 "post" => Some(Method::POST),
152 "put" => Some(Method::PUT),
153 "patch" => Some(Method::PATCH),
154 "delete" => Some(Method::DELETE),
155 "head" => Some(Method::HEAD),
156 _ => None,
157 };
158 if m.is_some() {
159 return (remainder.trim_start_matches('/'), m);
160 }
161 }
162 }
163 (path, None)
164}
165
166fn substitute_params(path: &str, params: &HashMap<String, String>) -> Result<String> {
167 let mut segments: Vec<Cow<'_, str>> = Vec::new();
170 for segment in path.split('/') {
171 match segment.strip_prefix(':') {
172 Some(name) => {
173 let Some(value) = params.get(name) else {
174 return Err(Error::MissingPathParam(name.to_string()));
175 };
176 segments.push(utf8_percent_encode(value, PATH_PARAM_ENCODE).into());
177 }
178 None => segments.push(Cow::Borrowed(segment)),
179 }
180 }
181 Ok(segments.join("/"))
182}
183
184fn join_path(base: &Url, path: &str) -> Result<Url> {
185 let path = path.trim_start_matches('/');
186 let base_str = base.as_str().trim_end_matches('/');
187 let joined = if path.is_empty() {
188 base_str.to_string()
189 } else {
190 format!("{base_str}/{path}")
191 };
192 Url::parse(&joined).map_err(Error::InvalidBaseUrl)
193}
194
195#[derive(Debug, Clone)]
197pub enum QueryValue {
198 Scalar(String),
200 Array(Vec<String>),
202}
203
204#[cfg(feature = "json")]
208pub fn serialize_to_query_map<T: serde::Serialize>(
209 value: &T,
210) -> Result<IndexMap<String, QueryValue>> {
211 let json = serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))?;
212 let mut map = IndexMap::new();
213 if let serde_json::Value::Object(obj) = json {
214 for (key, val) in obj {
215 if val.is_null() {
216 continue;
217 }
218 map.insert(key, QueryValue::from_serializable(&val)?);
219 }
220 }
221 Ok(map)
222}
223
224impl QueryValue {
225 #[cfg(feature = "json")]
227 pub fn from_serializable<T: serde::Serialize>(value: &T) -> Result<Self> {
228 match serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))? {
229 serde_json::Value::String(s) => Ok(Self::Scalar(s)),
230 serde_json::Value::Number(n) => Ok(Self::Scalar(n.to_string())),
231 serde_json::Value::Bool(b) => Ok(Self::Scalar(b.to_string())),
232 serde_json::Value::Array(arr) => {
233 let values: Vec<String> = arr
234 .into_iter()
235 .map(|v| match v {
236 serde_json::Value::String(s) => s,
237 other => other.to_string(),
238 })
239 .collect();
240 Ok(Self::Array(values))
241 }
242 other => Ok(Self::Scalar(other.to_string())),
243 }
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn base() -> Url {
252 Url::parse("https://api.example.com").unwrap()
253 }
254
255 #[test]
256 fn substitutes_colon_params() {
257 let mut params = HashMap::new();
258 params.insert("id".into(), "42".into());
259 let built = build_url(&base(), "/todos/:id", ¶ms, &IndexMap::new()).unwrap();
260 assert_eq!(built.url.as_str(), "https://api.example.com/todos/42");
261 }
262
263 #[test]
264 fn multiple_params() {
265 let mut params = HashMap::new();
266 params.insert("id".into(), "1".into());
267 params.insert("title".into(), "hello".into());
268 let built = build_url(&base(), "/post/:id/:title", ¶ms, &IndexMap::new()).unwrap();
269 assert_eq!(built.url.as_str(), "https://api.example.com/post/1/hello");
270 }
271
272 #[test]
273 fn encodes_special_characters_in_params() {
274 let mut params = HashMap::new();
275 params.insert("id".into(), "a/b".into());
276 let built = build_url(&base(), "/items/:id", ¶ms, &IndexMap::new()).unwrap();
277 assert!(built.url.path().contains("a%2Fb"));
278 }
279
280 #[test]
281 fn param_name_prefix_of_another_does_not_collide() {
282 let mut params = HashMap::new();
283 params.insert("user".into(), "alice".into());
284 params.insert("user_id".into(), "42".into());
285 let built = build_url(&base(), "/u/:user/:user_id", ¶ms, &IndexMap::new()).unwrap();
286 assert_eq!(built.url.as_str(), "https://api.example.com/u/alice/42");
287 }
288
289 #[test]
290 fn shorter_param_after_longer_does_not_collide() {
291 let mut params = HashMap::new();
292 params.insert("id".into(), "1".into());
293 params.insert("id_v2".into(), "2".into());
294 let built = build_url(&base(), "/x/:id_v2/:id", ¶ms, &IndexMap::new()).unwrap();
295 assert_eq!(built.url.as_str(), "https://api.example.com/x/2/1");
296 }
297
298 #[test]
299 fn missing_param_errors() {
300 let err = build_url(&base(), "/todos/:id", &HashMap::new(), &IndexMap::new()).unwrap_err();
301 assert!(matches!(err, Error::MissingPathParam(_)));
302 }
303
304 #[test]
305 fn embedded_query_in_path_is_merged() {
306 let built = build_url(
307 &base(),
308 "/search?tag=rust",
309 &HashMap::new(),
310 &IndexMap::new(),
311 )
312 .unwrap();
313 assert_eq!(built.url.query(), Some("tag=rust"));
314 }
315
316 #[test]
317 fn builder_query_overrides_embedded_query() {
318 let mut query = IndexMap::new();
319 query.insert("tag".into(), QueryValue::Scalar("override".into()));
320 let built = build_url(&base(), "/search?tag=rust", &HashMap::new(), &query).unwrap();
321 assert_eq!(built.url.query(), Some("tag=override"));
322 }
323
324 #[test]
325 fn query_scalar() {
326 let mut query = IndexMap::new();
327 query.insert("q".into(), QueryValue::Scalar("rust".into()));
328 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
329 assert_eq!(built.url.query(), Some("q=rust"));
330 }
331
332 #[test]
333 fn query_array() {
334 let mut query = IndexMap::new();
335 query.insert(
336 "tag".into(),
337 QueryValue::Array(vec!["a".into(), "b".into()]),
338 );
339 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
340 let q = built.url.query().unwrap();
341 assert!(q.contains("tag=a"));
342 assert!(q.contains("tag=b"));
343 }
344
345 #[test]
346 fn query_preserves_insertion_order() {
347 let mut query = IndexMap::new();
348 query.insert("z".into(), QueryValue::Scalar("1".into()));
349 query.insert("a".into(), QueryValue::Scalar("2".into()));
350 query.insert("m".into(), QueryValue::Scalar("3".into()));
351 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
352 assert_eq!(built.url.query(), Some("z=1&a=2&m=3"));
353 }
354
355 #[test]
356 fn method_modifier_put() {
357 let (path, method) = parse_method_modifier("@put/user");
358 assert_eq!(path, "user");
359 assert_eq!(method, Some(Method::PUT));
360 }
361
362 #[test]
363 fn method_modifier_in_build_url() {
364 let built = build_url(&base(), "@patch/items", &HashMap::new(), &IndexMap::new()).unwrap();
365 assert_eq!(built.url.path(), "/items");
366 assert_eq!(built.method_override, Some(Method::PATCH));
367 }
368
369 #[test]
370 fn absolute_url_ignores_base() {
371 let mut params = HashMap::new();
372 params.insert("id".into(), "5".into());
373 let built = build_url(
374 &base(),
375 "https://other.example.com/users/:id",
376 ¶ms,
377 &IndexMap::new(),
378 )
379 .unwrap();
380 assert_eq!(built.url.as_str(), "https://other.example.com/users/5");
381 }
382
383 #[test]
384 fn empty_path_uses_base() {
385 let built = build_url(&base(), "", &HashMap::new(), &IndexMap::new()).unwrap();
386 assert_eq!(built.url.as_str(), "https://api.example.com/");
387 }
388
389 #[cfg(feature = "json")]
390 mod serialize_tests {
391 use super::*;
392 use serde::Serialize;
393
394 #[derive(Serialize)]
395 struct SearchQuery {
396 q: String,
397 page: u32,
398 active: bool,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 tag: Option<String>,
401 }
402
403 #[test]
404 fn serialize_to_query_map_skips_null_and_serializes_fields() {
405 let value = SearchQuery {
406 q: "rust".into(),
407 page: 2,
408 active: true,
409 tag: None,
410 };
411 let map = serialize_to_query_map(&value).unwrap();
412 assert_eq!(map.len(), 3);
413 assert!(matches!(map.get("q"), Some(QueryValue::Scalar(s)) if s == "rust"));
414 assert!(matches!(map.get("page"), Some(QueryValue::Scalar(s)) if s == "2"));
415 assert!(matches!(map.get("active"), Some(QueryValue::Scalar(s)) if s == "true"));
416 assert!(!map.contains_key("tag"));
417 }
418
419 #[test]
420 fn serialize_to_query_map_array_field() {
421 #[derive(Serialize)]
422 struct Tags {
423 tags: Vec<String>,
424 }
425 let value = Tags {
426 tags: vec!["a".into(), "b".into()],
427 };
428 let map = serialize_to_query_map(&value).unwrap();
429 assert!(matches!(map.get("tags"), Some(QueryValue::Array(v)) if v == &["a", "b"]));
430 }
431 }
432}
433
434#[cfg(test)]
435mod proptests {
436 use super::*;
437 use proptest::prelude::*;
438
439 fn base() -> Url {
440 Url::parse("https://api.example.com").unwrap()
441 }
442
443 proptest! {
444 #[test]
445 fn substitute_preserves_literal_segments(path in r"([a-z]+/)*[a-z]+") {
446 let mut params = HashMap::new();
447 params.insert("id".into(), "42".into());
448 let template = format!("/{path}/:id");
449 let built = build_url(&base(), &template, ¶ms, &IndexMap::new()).unwrap();
450 prop_assert!(built.url.as_str().ends_with("/42"));
451 }
452
453 #[test]
454 fn scalar_query_round_trips_key_value(
455 key in r"[a-zA-Z][a-zA-Z0-9_-]{0,15}",
456 value in r"[a-zA-Z0-9._-]{0,32}",
457 ) {
458 let mut query = IndexMap::new();
459 query.insert(key.clone(), QueryValue::Scalar(value.clone()));
460 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
461 let q = built.url.query().unwrap();
462 let needle = format!("{key}={value}");
463 prop_assert!(q.contains(&needle));
464 }
465
466 #[test]
467 fn builder_query_overrides_embedded_key(
468 key in r"[a-z][a-z0-9]{0,8}",
469 embedded in r"[a-z0-9]{1,12}",
470 override_val in r"[a-z0-9]{1,12}",
471 ) {
472 let _ = embedded;
473 let mut query = IndexMap::new();
474 query.insert(key.clone(), QueryValue::Scalar(override_val.clone()));
475 let path = format!("/search?{key}={embedded}");
476 let built = build_url(&base(), &path, &HashMap::new(), &query).unwrap();
477 let q = built.url.query().unwrap();
478 let parsed: std::collections::HashMap<String, String> =
479 url::form_urlencoded::parse(q.as_bytes())
480 .map(|(k, v)| (k.into_owned(), v.into_owned()))
481 .collect();
482 prop_assert_eq!(parsed.get(&key).map(String::as_str), Some(override_val.as_str()));
483 }
484
485 #[test]
486 fn missing_path_param_always_errors(
487 name in r"[a-z][a-z0-9]{0,12}",
488 ) {
489 let template = format!("/items/:{name}");
490 let err = build_url(&base(), &template, &HashMap::new(), &IndexMap::new()).unwrap_err();
491 prop_assert!(matches!(err, Error::MissingPathParam(_)));
492 }
493
494 #[test]
495 fn join_path_preserves_base_for_empty_path(
496 trailing in prop::bool::ANY,
497 ) {
498 let base_str = if trailing {
499 "https://api.example.com/"
500 } else {
501 "https://api.example.com"
502 };
503 let base_url = Url::parse(base_str).unwrap();
504 let built = build_url(&base_url, "", &HashMap::new(), &IndexMap::new()).unwrap();
505 prop_assert_eq!(built.url.as_str(), "https://api.example.com/");
506 }
507
508 #[test]
509 fn array_query_repeats_key(
510 key in r"[a-z][a-z0-9]{0,6}",
511 a in r"[a-z0-9]{1,8}",
512 b in r"[a-z0-9]{1,8}",
513 ) {
514 let mut query = IndexMap::new();
515 query.insert(key.clone(), QueryValue::Array(vec![a.clone(), b.clone()]));
516 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
517 let q = built.url.query().unwrap();
518 let a_needle = format!("{key}={a}");
519 let b_needle = format!("{key}={b}");
520 prop_assert!(q.contains(&a_needle));
521 prop_assert!(q.contains(&b_needle));
522 }
523 }
524}