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 result = path.to_string();
168 for key in path_param_names(path) {
169 let placeholder = format!(":{key}");
170 let Some(value) = params.get(&key) else {
171 return Err(Error::MissingPathParam(key));
172 };
173 let encoded: Cow<'_, str> = utf8_percent_encode(value, PATH_PARAM_ENCODE).into();
174 result = result.replace(&placeholder, encoded.as_ref());
175 }
176
177 if result.contains(':') {
178 for segment in result.split('/') {
179 if segment.starts_with(':') {
180 let name = segment.trim_start_matches(':');
181 return Err(Error::MissingPathParam(name.to_string()));
182 }
183 }
184 }
185
186 Ok(result)
187}
188
189fn join_path(base: &Url, path: &str) -> Result<Url> {
190 let path = path.trim_start_matches('/');
191 let base_str = base.as_str().trim_end_matches('/');
192 let joined = if path.is_empty() {
193 base_str.to_string()
194 } else {
195 format!("{base_str}/{path}")
196 };
197 Url::parse(&joined).map_err(Error::InvalidBaseUrl)
198}
199
200#[derive(Debug, Clone)]
202pub enum QueryValue {
203 Scalar(String),
205 Array(Vec<String>),
207}
208
209#[cfg(feature = "json")]
213pub fn serialize_to_query_map<T: serde::Serialize>(
214 value: &T,
215) -> Result<IndexMap<String, QueryValue>> {
216 let json = serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))?;
217 let mut map = IndexMap::new();
218 if let serde_json::Value::Object(obj) = json {
219 for (key, val) in obj {
220 if val.is_null() {
221 continue;
222 }
223 map.insert(key, QueryValue::from_serializable(&val)?);
224 }
225 }
226 Ok(map)
227}
228
229impl QueryValue {
230 #[cfg(feature = "json")]
232 pub fn from_serializable<T: serde::Serialize>(value: &T) -> Result<Self> {
233 match serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))? {
234 serde_json::Value::String(s) => Ok(Self::Scalar(s)),
235 serde_json::Value::Number(n) => Ok(Self::Scalar(n.to_string())),
236 serde_json::Value::Bool(b) => Ok(Self::Scalar(b.to_string())),
237 serde_json::Value::Array(arr) => {
238 let values: Vec<String> = arr
239 .into_iter()
240 .map(|v| match v {
241 serde_json::Value::String(s) => s,
242 other => other.to_string(),
243 })
244 .collect();
245 Ok(Self::Array(values))
246 }
247 other => Ok(Self::Scalar(other.to_string())),
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn base() -> Url {
257 Url::parse("https://api.example.com").unwrap()
258 }
259
260 #[test]
261 fn substitutes_colon_params() {
262 let mut params = HashMap::new();
263 params.insert("id".into(), "42".into());
264 let built = build_url(&base(), "/todos/:id", ¶ms, &IndexMap::new()).unwrap();
265 assert_eq!(built.url.as_str(), "https://api.example.com/todos/42");
266 }
267
268 #[test]
269 fn multiple_params() {
270 let mut params = HashMap::new();
271 params.insert("id".into(), "1".into());
272 params.insert("title".into(), "hello".into());
273 let built = build_url(&base(), "/post/:id/:title", ¶ms, &IndexMap::new()).unwrap();
274 assert_eq!(built.url.as_str(), "https://api.example.com/post/1/hello");
275 }
276
277 #[test]
278 fn encodes_special_characters_in_params() {
279 let mut params = HashMap::new();
280 params.insert("id".into(), "a/b".into());
281 let built = build_url(&base(), "/items/:id", ¶ms, &IndexMap::new()).unwrap();
282 assert!(built.url.path().contains("a%2Fb"));
283 }
284
285 #[test]
286 fn missing_param_errors() {
287 let err = build_url(&base(), "/todos/:id", &HashMap::new(), &IndexMap::new()).unwrap_err();
288 assert!(matches!(err, Error::MissingPathParam(_)));
289 }
290
291 #[test]
292 fn embedded_query_in_path_is_merged() {
293 let built = build_url(
294 &base(),
295 "/search?tag=rust",
296 &HashMap::new(),
297 &IndexMap::new(),
298 )
299 .unwrap();
300 assert_eq!(built.url.query(), Some("tag=rust"));
301 }
302
303 #[test]
304 fn builder_query_overrides_embedded_query() {
305 let mut query = IndexMap::new();
306 query.insert("tag".into(), QueryValue::Scalar("override".into()));
307 let built = build_url(&base(), "/search?tag=rust", &HashMap::new(), &query).unwrap();
308 assert_eq!(built.url.query(), Some("tag=override"));
309 }
310
311 #[test]
312 fn query_scalar() {
313 let mut query = IndexMap::new();
314 query.insert("q".into(), QueryValue::Scalar("rust".into()));
315 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
316 assert_eq!(built.url.query(), Some("q=rust"));
317 }
318
319 #[test]
320 fn query_array() {
321 let mut query = IndexMap::new();
322 query.insert(
323 "tag".into(),
324 QueryValue::Array(vec!["a".into(), "b".into()]),
325 );
326 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
327 let q = built.url.query().unwrap();
328 assert!(q.contains("tag=a"));
329 assert!(q.contains("tag=b"));
330 }
331
332 #[test]
333 fn query_preserves_insertion_order() {
334 let mut query = IndexMap::new();
335 query.insert("z".into(), QueryValue::Scalar("1".into()));
336 query.insert("a".into(), QueryValue::Scalar("2".into()));
337 query.insert("m".into(), QueryValue::Scalar("3".into()));
338 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
339 assert_eq!(built.url.query(), Some("z=1&a=2&m=3"));
340 }
341
342 #[test]
343 fn method_modifier_put() {
344 let (path, method) = parse_method_modifier("@put/user");
345 assert_eq!(path, "user");
346 assert_eq!(method, Some(Method::PUT));
347 }
348
349 #[test]
350 fn method_modifier_in_build_url() {
351 let built = build_url(&base(), "@patch/items", &HashMap::new(), &IndexMap::new()).unwrap();
352 assert_eq!(built.url.path(), "/items");
353 assert_eq!(built.method_override, Some(Method::PATCH));
354 }
355
356 #[test]
357 fn absolute_url_ignores_base() {
358 let mut params = HashMap::new();
359 params.insert("id".into(), "5".into());
360 let built = build_url(
361 &base(),
362 "https://other.example.com/users/:id",
363 ¶ms,
364 &IndexMap::new(),
365 )
366 .unwrap();
367 assert_eq!(built.url.as_str(), "https://other.example.com/users/5");
368 }
369
370 #[test]
371 fn empty_path_uses_base() {
372 let built = build_url(&base(), "", &HashMap::new(), &IndexMap::new()).unwrap();
373 assert_eq!(built.url.as_str(), "https://api.example.com/");
374 }
375
376 #[cfg(feature = "json")]
377 mod serialize_tests {
378 use super::*;
379 use serde::Serialize;
380
381 #[derive(Serialize)]
382 struct SearchQuery {
383 q: String,
384 page: u32,
385 active: bool,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 tag: Option<String>,
388 }
389
390 #[test]
391 fn serialize_to_query_map_skips_null_and_serializes_fields() {
392 let value = SearchQuery {
393 q: "rust".into(),
394 page: 2,
395 active: true,
396 tag: None,
397 };
398 let map = serialize_to_query_map(&value).unwrap();
399 assert_eq!(map.len(), 3);
400 assert!(matches!(map.get("q"), Some(QueryValue::Scalar(s)) if s == "rust"));
401 assert!(matches!(map.get("page"), Some(QueryValue::Scalar(s)) if s == "2"));
402 assert!(matches!(map.get("active"), Some(QueryValue::Scalar(s)) if s == "true"));
403 assert!(!map.contains_key("tag"));
404 }
405
406 #[test]
407 fn serialize_to_query_map_array_field() {
408 #[derive(Serialize)]
409 struct Tags {
410 tags: Vec<String>,
411 }
412 let value = Tags {
413 tags: vec!["a".into(), "b".into()],
414 };
415 let map = serialize_to_query_map(&value).unwrap();
416 assert!(matches!(map.get("tags"), Some(QueryValue::Array(v)) if v == &["a", "b"]));
417 }
418 }
419}
420
421#[cfg(test)]
422mod proptests {
423 use super::*;
424 use proptest::prelude::*;
425
426 fn base() -> Url {
427 Url::parse("https://api.example.com").unwrap()
428 }
429
430 proptest! {
431 #[test]
432 fn substitute_preserves_literal_segments(path in r"([a-z]+/)*[a-z]+") {
433 let mut params = HashMap::new();
434 params.insert("id".into(), "42".into());
435 let template = format!("/{path}/:id");
436 let built = build_url(&base(), &template, ¶ms, &IndexMap::new()).unwrap();
437 prop_assert!(built.url.as_str().ends_with("/42"));
438 }
439
440 #[test]
441 fn scalar_query_round_trips_key_value(
442 key in r"[a-zA-Z][a-zA-Z0-9_-]{0,15}",
443 value in r"[a-zA-Z0-9._-]{0,32}",
444 ) {
445 let mut query = IndexMap::new();
446 query.insert(key.clone(), QueryValue::Scalar(value.clone()));
447 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
448 let q = built.url.query().unwrap();
449 let needle = format!("{key}={value}");
450 prop_assert!(q.contains(&needle));
451 }
452
453 #[test]
454 fn builder_query_overrides_embedded_key(
455 key in r"[a-z][a-z0-9]{0,8}",
456 embedded in r"[a-z0-9]{1,12}",
457 override_val in r"[a-z0-9]{1,12}",
458 ) {
459 let _ = embedded;
460 let mut query = IndexMap::new();
461 query.insert(key.clone(), QueryValue::Scalar(override_val.clone()));
462 let path = format!("/search?{key}={embedded}");
463 let built = build_url(&base(), &path, &HashMap::new(), &query).unwrap();
464 let q = built.url.query().unwrap();
465 let parsed: std::collections::HashMap<String, String> =
466 url::form_urlencoded::parse(q.as_bytes())
467 .map(|(k, v)| (k.into_owned(), v.into_owned()))
468 .collect();
469 prop_assert_eq!(parsed.get(&key).map(String::as_str), Some(override_val.as_str()));
470 }
471
472 #[test]
473 fn missing_path_param_always_errors(
474 name in r"[a-z][a-z0-9]{0,12}",
475 ) {
476 let template = format!("/items/:{name}");
477 let err = build_url(&base(), &template, &HashMap::new(), &IndexMap::new()).unwrap_err();
478 prop_assert!(matches!(err, Error::MissingPathParam(_)));
479 }
480
481 #[test]
482 fn join_path_preserves_base_for_empty_path(
483 trailing in prop::bool::ANY,
484 ) {
485 let base_str = if trailing {
486 "https://api.example.com/"
487 } else {
488 "https://api.example.com"
489 };
490 let base_url = Url::parse(base_str).unwrap();
491 let built = build_url(&base_url, "", &HashMap::new(), &IndexMap::new()).unwrap();
492 prop_assert_eq!(built.url.as_str(), "https://api.example.com/");
493 }
494
495 #[test]
496 fn array_query_repeats_key(
497 key in r"[a-z][a-z0-9]{0,6}",
498 a in r"[a-z0-9]{1,8}",
499 b in r"[a-z0-9]{1,8}",
500 ) {
501 let mut query = IndexMap::new();
502 query.insert(key.clone(), QueryValue::Array(vec![a.clone(), b.clone()]));
503 let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
504 let q = built.url.query().unwrap();
505 let a_needle = format!("{key}={a}");
506 let b_needle = format!("{key}={b}");
507 prop_assert!(q.contains(&a_needle));
508 prop_assert!(q.contains(&b_needle));
509 }
510 }
511}