Skip to main content

better_fetch/
url_build.rs

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
12/// RFC 3986 unreserved — same set as the former inline path param encoder.
13const PATH_PARAM_ENCODE: &AsciiSet = &NON_ALPHANUMERIC
14    .remove(b'-')
15    .remove(b'_')
16    .remove(b'.')
17    .remove(b'~');
18
19/// Result of building a request URL.
20#[derive(Debug, Clone)]
21pub struct BuiltUrl {
22    pub url: Url,
23    pub method_override: Option<Method>,
24}
25
26/// Returns `:param` segment names in left-to-right path order (ignores an embedded `?query`).
27pub fn path_param_names(path: &str) -> Vec<String> {
28    crate::path_params::path_param_names(path)
29}
30
31/// Fuzzing entry point: builds a URL from `path` against a fixed base (no params/query).
32#[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/// Fuzzing entry point: merges an embedded `?query` suffix from `path` with an empty builder query.
43#[doc(hidden)]
44pub fn fuzz_parse_embedded_query(path: &str) -> Result<BuiltUrl> {
45    fuzz_build_url(path)
46}
47
48/// Build a request URL from base URL, path template, params, and query.
49///
50/// Query keys are serialized in insertion order ([`IndexMap`]). A `?foo=bar` suffix on `path`
51/// is merged first; explicit `query` entries override embedded keys.
52pub 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
145/// Parse `@put/foo` style path modifiers; returns stripped path and optional HTTP method.
146pub 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    // Substitute per `/`-segment so a parameter name that is a prefix of another
168    // (e.g. `:user` vs `:user_id`) cannot corrupt a later segment.
169    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/// Query parameter value (scalar or repeated).
196#[derive(Debug, Clone)]
197pub enum QueryValue {
198    /// Single query value.
199    Scalar(String),
200    /// Repeated query key (`key=a&key=b`).
201    Array(Vec<String>),
202}
203
204/// Converts a serializable struct into query parameters keyed by serde field names.
205///
206/// Skips `null` values (e.g. `None` fields without `skip_serializing_if`).
207#[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    /// Encodes a serializable value as a scalar or array query param (feature `json`).
226    #[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", &params, &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", &params, &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", &params, &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", &params, &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", &params, &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            &params,
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, &params, &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}