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/// Build a request URL from base URL, path template, params, and query.
27///
28/// Query keys are serialized in insertion order ([`IndexMap`]).
29pub fn build_url(
30    base: &Url,
31    path: &str,
32    params: &HashMap<String, String>,
33    query: &IndexMap<String, QueryValue>,
34) -> Result<BuiltUrl> {
35    if path.starts_with("http://") || path.starts_with("https://") {
36        let (path_only, method_override) = parse_method_modifier(path);
37        let resolved_path = substitute_params(path_only, params)?;
38        let mut url = Url::parse(&resolved_path).map_err(Error::InvalidBaseUrl)?;
39        apply_query(&mut url, query)?;
40        return Ok(BuiltUrl {
41            url,
42            method_override,
43        });
44    }
45
46    let (path_only, method_override) = parse_method_modifier(path);
47    let resolved_path = substitute_params(path_only, params)?;
48    let mut url = join_path(base, &resolved_path)?;
49    apply_query(&mut url, query)?;
50
51    Ok(BuiltUrl {
52        url,
53        method_override,
54    })
55}
56
57fn apply_query(url: &mut Url, query: &IndexMap<String, QueryValue>) -> Result<()> {
58    if query.is_empty() {
59        return Ok(());
60    }
61    let mut pairs = url::form_urlencoded::Serializer::new(String::new());
62    for (key, value) in query {
63        match value {
64            QueryValue::Scalar(v) => {
65                pairs.append_pair(key, v);
66            }
67            QueryValue::Array(values) => {
68                for v in values {
69                    pairs.append_pair(key, v);
70                }
71            }
72        }
73    }
74    let query_string = pairs.finish();
75    url.set_query(Some(&query_string));
76    Ok(())
77}
78
79/// Parse `@put/foo` style path modifiers; returns stripped path and optional HTTP method.
80pub fn parse_method_modifier(path: &str) -> (&str, Option<Method>) {
81    if let Some(rest) = path.strip_prefix('@') {
82        if let Some((method, remainder)) = rest.split_once('/') {
83            let m = match method.to_ascii_lowercase().as_str() {
84                "get" => Some(Method::GET),
85                "post" => Some(Method::POST),
86                "put" => Some(Method::PUT),
87                "patch" => Some(Method::PATCH),
88                "delete" => Some(Method::DELETE),
89                "head" => Some(Method::HEAD),
90                _ => None,
91            };
92            if m.is_some() {
93                return (remainder.trim_start_matches('/'), m);
94            }
95        }
96    }
97    (path, None)
98}
99
100fn substitute_params(path: &str, params: &HashMap<String, String>) -> Result<String> {
101    let mut result = path.to_string();
102    for (key, value) in params {
103        let placeholder = format!(":{key}");
104        if !result.contains(&placeholder) {
105            continue;
106        }
107        let encoded: Cow<'_, str> = utf8_percent_encode(value, PATH_PARAM_ENCODE).into();
108        result = result.replace(&placeholder, encoded.as_ref());
109    }
110
111    if result.contains(':') {
112        for segment in result.split('/') {
113            if segment.starts_with(':') {
114                return Err(Error::Other(format!(
115                    "missing path parameter for `{}`",
116                    segment
117                )));
118            }
119        }
120    }
121
122    Ok(result)
123}
124
125fn join_path(base: &Url, path: &str) -> Result<Url> {
126    let path = path.trim_start_matches('/');
127    let base_str = base.as_str().trim_end_matches('/');
128    let joined = if path.is_empty() {
129        base_str.to_string()
130    } else {
131        format!("{base_str}/{path}")
132    };
133    Url::parse(&joined).map_err(Error::InvalidBaseUrl)
134}
135
136/// Query parameter value (scalar or repeated).
137#[derive(Debug, Clone)]
138pub enum QueryValue {
139    /// Single query value.
140    Scalar(String),
141    /// Repeated query key (`key=a&key=b`).
142    Array(Vec<String>),
143}
144
145/// Converts a serializable struct into query parameters keyed by serde field names.
146///
147/// Skips `null` values (e.g. `None` fields without `skip_serializing_if`).
148#[cfg(feature = "json")]
149pub fn serialize_to_query_map<T: serde::Serialize>(
150    value: &T,
151) -> Result<IndexMap<String, QueryValue>> {
152    let json = serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))?;
153    let mut map = IndexMap::new();
154    if let serde_json::Value::Object(obj) = json {
155        for (key, val) in obj {
156            if val.is_null() {
157                continue;
158            }
159            map.insert(key, QueryValue::from_serializable(&val)?);
160        }
161    }
162    Ok(map)
163}
164
165impl QueryValue {
166    /// Encodes a serializable value as a scalar or array query param (feature `json`).
167    #[cfg(feature = "json")]
168    pub fn from_serializable<T: serde::Serialize>(value: &T) -> Result<Self> {
169        match serde_json::to_value(value).map_err(|e| Error::Other(e.to_string()))? {
170            serde_json::Value::String(s) => Ok(Self::Scalar(s)),
171            serde_json::Value::Number(n) => Ok(Self::Scalar(n.to_string())),
172            serde_json::Value::Bool(b) => Ok(Self::Scalar(b.to_string())),
173            serde_json::Value::Array(arr) => {
174                let values: Vec<String> = arr
175                    .into_iter()
176                    .map(|v| match v {
177                        serde_json::Value::String(s) => s,
178                        other => other.to_string(),
179                    })
180                    .collect();
181                Ok(Self::Array(values))
182            }
183            other => Ok(Self::Scalar(other.to_string())),
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn base() -> Url {
193        Url::parse("https://api.example.com").unwrap()
194    }
195
196    #[test]
197    fn substitutes_colon_params() {
198        let mut params = HashMap::new();
199        params.insert("id".into(), "42".into());
200        let built = build_url(&base(), "/todos/:id", &params, &IndexMap::new()).unwrap();
201        assert_eq!(built.url.as_str(), "https://api.example.com/todos/42");
202    }
203
204    #[test]
205    fn multiple_params() {
206        let mut params = HashMap::new();
207        params.insert("id".into(), "1".into());
208        params.insert("title".into(), "hello".into());
209        let built = build_url(&base(), "/post/:id/:title", &params, &IndexMap::new()).unwrap();
210        assert_eq!(built.url.as_str(), "https://api.example.com/post/1/hello");
211    }
212
213    #[test]
214    fn encodes_special_characters_in_params() {
215        let mut params = HashMap::new();
216        params.insert("id".into(), "a/b".into());
217        let built = build_url(&base(), "/items/:id", &params, &IndexMap::new()).unwrap();
218        assert!(built.url.path().contains("a%2Fb"));
219    }
220
221    #[test]
222    fn missing_param_errors() {
223        let err = build_url(&base(), "/todos/:id", &HashMap::new(), &IndexMap::new()).unwrap_err();
224        assert!(matches!(err, Error::Other(_)));
225    }
226
227    #[test]
228    fn query_scalar() {
229        let mut query = IndexMap::new();
230        query.insert("q".into(), QueryValue::Scalar("rust".into()));
231        let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
232        assert_eq!(built.url.query(), Some("q=rust"));
233    }
234
235    #[test]
236    fn query_array() {
237        let mut query = IndexMap::new();
238        query.insert(
239            "tag".into(),
240            QueryValue::Array(vec!["a".into(), "b".into()]),
241        );
242        let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
243        let q = built.url.query().unwrap();
244        assert!(q.contains("tag=a"));
245        assert!(q.contains("tag=b"));
246    }
247
248    #[test]
249    fn query_preserves_insertion_order() {
250        let mut query = IndexMap::new();
251        query.insert("z".into(), QueryValue::Scalar("1".into()));
252        query.insert("a".into(), QueryValue::Scalar("2".into()));
253        query.insert("m".into(), QueryValue::Scalar("3".into()));
254        let built = build_url(&base(), "/search", &HashMap::new(), &query).unwrap();
255        assert_eq!(built.url.query(), Some("z=1&a=2&m=3"));
256    }
257
258    #[test]
259    fn method_modifier_put() {
260        let (path, method) = parse_method_modifier("@put/user");
261        assert_eq!(path, "user");
262        assert_eq!(method, Some(Method::PUT));
263    }
264
265    #[test]
266    fn method_modifier_in_build_url() {
267        let built = build_url(&base(), "@patch/items", &HashMap::new(), &IndexMap::new()).unwrap();
268        assert_eq!(built.url.path(), "/items");
269        assert_eq!(built.method_override, Some(Method::PATCH));
270    }
271
272    #[test]
273    fn absolute_url_ignores_base() {
274        let mut params = HashMap::new();
275        params.insert("id".into(), "5".into());
276        let built = build_url(
277            &base(),
278            "https://other.example.com/users/:id",
279            &params,
280            &IndexMap::new(),
281        )
282        .unwrap();
283        assert_eq!(built.url.as_str(), "https://other.example.com/users/5");
284    }
285
286    #[test]
287    fn empty_path_uses_base() {
288        let built = build_url(&base(), "", &HashMap::new(), &IndexMap::new()).unwrap();
289        assert_eq!(built.url.as_str(), "https://api.example.com/");
290    }
291}
292
293#[cfg(test)]
294mod proptests {
295    use super::*;
296    use proptest::prelude::*;
297
298    proptest! {
299        #[test]
300        fn substitute_preserves_literal_segments(path in r"([a-z]+/)*[a-z]+") {
301            let mut params = HashMap::new();
302            params.insert("id".into(), "42".into());
303            let template = format!("/{path}/:id");
304            let built = build_url(
305                &Url::parse("https://api.example.com").unwrap(),
306                &template,
307                &params,
308                &IndexMap::new(),
309            )
310            .unwrap();
311            prop_assert!(built.url.as_str().ends_with("/42"));
312        }
313    }
314}