format_url/
lib.rs

1//! Format URLs for fetch requests using templates and substitution values.
2//!
3//! ## Usage
4//! ```
5//! use format_url::FormatUrl;
6//!
7//! let url = FormatUrl::new("https://api.example.com/")
8//!     .with_path_template("/user/:name")
9//!     .with_substitutes(vec![("name", "alex")])
10//!     .with_query_params(vec![("active", "true")])
11//!     .format_url();
12//!
13//! assert_eq!(url, "https://api.example.com/user/alex?active=true");
14//! ```
15//!
16//! ## Wishlist
17//! * Support for lists and nested values. (serde_urlencoded -> serde_qs)
18//! * Support receiving query params as any value serde_urlencoded or serde_qs can serialize.
19//! * Support receiving path template substitutes as a (Hash)Map, perhaps even a struct with
20//! matching fields.
21
22use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
23
24type SubstitutePairs<'a> = Vec<(&'a str, &'a str)>;
25type QueryParams<'a> = Vec<(&'a str, &'a str)>;
26
27fn strip_double_slash<'a>(base_url: &str, route_template: &'a str) -> &'a str {
28    if base_url.ends_with("/") && route_template.starts_with("/") {
29        &route_template[1..]
30    } else {
31        route_template
32    }
33}
34
35fn format_path(route_template: &str, substitutes: &SubstitutePairs) -> String {
36    substitutes
37        .iter()
38        .fold(route_template.to_owned(), |route, (key, value)| {
39            route.replace(
40                &format!(":{key}"),
41                &utf8_percent_encode(&value, NON_ALPHANUMERIC).to_string(),
42            )
43        })
44}
45
46fn naive_encode_query_string<'a>(query_params: &QueryParams<'a>) -> String {
47    let query_string = query_params
48        .iter()
49        .map(|(key, value)| {
50            format!(
51                "{}={}",
52                utf8_percent_encode(key, NON_ALPHANUMERIC),
53                utf8_percent_encode(value, NON_ALPHANUMERIC)
54            )
55        })
56        .collect::<Vec<String>>()
57        .join("&");
58
59    "?".to_string() + (&query_string)
60}
61
62/// A collection of all the components and configuration that together serialize into a URL.
63pub struct FormatUrl<'a> {
64    base: &'a str,
65    disable_encoding: bool,
66    path_template: Option<&'a str>,
67    query_params: Option<QueryParams<'a>>,
68    substitutes: Option<SubstitutePairs<'a>>,
69}
70
71impl<'a> FormatUrl<'a> {
72    /// In rare cases you may need the query parameter key/value pairs not to be encoded.
73    pub fn disable_encoding(mut self) -> Self {
74        self.disable_encoding = true;
75        self
76    }
77
78    /// Takes all of the provided arguments and turns them into a single URL to fetch.
79    pub fn format_url(self) -> String {
80        let formatted_path = match (self.path_template, &self.substitutes) {
81            (Some(path_template), Some(substitutes)) => format_path(path_template, &substitutes),
82            (Some(path_template), _) => path_template.to_string(),
83            _ => String::from(""),
84        };
85
86        let formatted_querystring = &self.query_params.map_or_else(
87            || String::new(),
88            |query_params| match self.disable_encoding {
89                false => naive_encode_query_string(&query_params),
90                true => {
91                    let query_string = query_params
92                        .iter()
93                        .map(|(key, value)| format!("{key}={value}"))
94                        .collect::<Vec<String>>()
95                        .join("&");
96                    "?".to_string() + &query_string
97                }
98            },
99        );
100
101        let safe_formatted_route = strip_double_slash(self.base, &formatted_path);
102
103        format!(
104            "{}{}{}",
105            self.base, safe_formatted_route, formatted_querystring
106        )
107    }
108
109    /// Start building a URL. The minimum required is some hostname.
110    pub fn new(base: &'a str) -> Self {
111        Self {
112            base,
113            disable_encoding: false,
114            path_template: None,
115            query_params: None,
116            substitutes: None,
117        }
118    }
119
120    /// Add a path, optionally marking sections for substitution using `:key`.
121    pub fn with_path_template(mut self, path_template: &'a str) -> Self {
122        self.path_template = Some(path_template);
123        self
124    }
125
126    /// Add some query parameters.
127    pub fn with_query_params(mut self, params: QueryParams<'a>) -> Self {
128        self.query_params = Some(params);
129        self
130    }
131
132    /// Add substitutes to substitute matching `:key` sequences in the path template.
133    pub fn with_substitutes(mut self, substitutes: SubstitutePairs<'a>) -> Self {
134        self.substitutes = Some(substitutes);
135        self
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use crate::FormatUrl;
142
143    #[test]
144    fn no_formatting_test() {
145        assert_eq!(
146            FormatUrl::new("https://api.example.com").format_url(),
147            "https://api.example.com".to_string()
148        );
149    }
150
151    #[test]
152    fn path_test() {
153        assert_eq!(
154            FormatUrl::new("https://api.example.com",)
155                .with_path_template("/user")
156                .format_url(),
157            "https://api.example.com/user"
158        );
159    }
160
161    #[test]
162    fn strip_double_slash_test() {
163        assert_eq!(
164            FormatUrl::new("https://api.example.com/")
165                .with_path_template("/user")
166                .format_url(),
167            "https://api.example.com/user"
168        );
169    }
170
171    #[test]
172    fn path_substitutes_test() {
173        assert_eq!(
174            FormatUrl::new("https://api.example.com/",)
175                .with_path_template("/user/:id",)
176                .with_substitutes(vec![("id", "alextes")])
177                .format_url(),
178            "https://api.example.com/user/alextes"
179        );
180    }
181
182    #[test]
183    fn querystring_test() {
184        assert_eq!(
185            FormatUrl::new("https://api.example.com/user",)
186                .with_query_params(vec![("id", "alextes")],)
187                .format_url(),
188            "https://api.example.com/user?id=alextes"
189        );
190    }
191
192    #[test]
193    fn percent_encode_substitutes_test() {
194        assert_eq!(
195            FormatUrl::new("https://api.example.com/",)
196                .with_path_template("/user/:id",)
197                .with_substitutes(vec![("id", "alex tes")])
198                .format_url(),
199            "https://api.example.com/user/alex%20tes"
200        )
201    }
202
203    #[test]
204    fn percent_encode_query_params_test() {
205        assert_eq!(
206            FormatUrl::new("https://api.example.com/user",)
207                .with_query_params(vec![("id", "alex+tes")],)
208                .format_url(),
209            "https://api.example.com/user?id=alex%2Btes"
210        )
211    }
212
213    #[test]
214    fn disable_encoding_test() {
215        assert_eq!(
216            FormatUrl::new("https://api.example.com/user",)
217                .with_query_params(vec![("id", "alex+tes")],)
218                .disable_encoding()
219                .format_url(),
220            "https://api.example.com/user?id=alex+tes"
221        )
222    }
223}