Skip to main content

better_fetch/
url_build.rs

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