1use std::collections::HashMap;
2
3use http::Method;
4use indexmap::IndexMap;
5use url::Url;
6
7use crate::error::Error;
8use crate::Result;
9
10#[derive(Debug, Clone)]
12pub struct BuiltUrl {
13 pub url: Url,
14 pub method_override: Option<Method>,
15}
16
17pub 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
70pub 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#[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", ¶ms, &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", ¶ms, &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", ¶ms, &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 ¶ms,
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 ¶ms,
291 &IndexMap::new(),
292 )
293 .unwrap();
294 prop_assert!(built.url.as_str().ends_with("/42"));
295 }
296 }
297}