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/// Build a request URL from base URL, path template, params, and query.
32///
33/// Query keys are serialized in insertion order ([`IndexMap`]). A `?foo=bar` suffix on `path`
34/// is merged first; explicit `query` entries override embedded keys.
35pub 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
128/// Parse `@put/foo` style path modifiers; returns stripped path and optional HTTP method.
129pub 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/// Query parameter value (scalar or repeated).
184#[derive(Debug, Clone)]
185pub enum QueryValue {
186    /// Single query value.
187    Scalar(String),
188    /// Repeated query key (`key=a&key=b`).
189    Array(Vec<String>),
190}
191
192/// Converts a serializable struct into query parameters keyed by serde field names.
193///
194/// Skips `null` values (e.g. `None` fields without `skip_serializing_if`).
195#[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    /// Encodes a serializable value as a scalar or array query param (feature `json`).
214    #[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", &params, &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", &params, &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", &params, &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            &params,
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                &params,
375                &IndexMap::new(),
376            )
377            .unwrap();
378            prop_assert!(built.url.as_str().ends_with("/42"));
379        }
380    }
381}