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    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/// Query parameter value (scalar or repeated).
201#[derive(Debug, Clone)]
202pub enum QueryValue {
203    /// Single query value.
204    Scalar(String),
205    /// Repeated query key (`key=a&key=b`).
206    Array(Vec<String>),
207}
208
209/// Converts a serializable struct into query parameters keyed by serde field names.
210///
211/// Skips `null` values (e.g. `None` fields without `skip_serializing_if`).
212#[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    /// Encodes a serializable value as a scalar or array query param (feature `json`).
231    #[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", &params, &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", &params, &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", &params, &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            &params,
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, &params, &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}