query_string_builder/lib.rs
1//! # A query string builder for percent encoding key-value pairs
2//!
3//! This is a tiny helper crate for simplifying the construction of URL query strings.
4//! The initial `?` question mark is automatically prepended.
5//!
6//! ## Example
7//!
8//! ```
9//! use query_string_builder::QueryString;
10//!
11//! let qs = QueryString::dynamic()
12//! .with_value("q", "🍎 apple")
13//! .with_value("tasty", true)
14//! .with_opt_value("color", None::<String>)
15//! .with_opt_value("category", Some("fruits and vegetables?"));
16//!
17//! assert_eq!(
18//! format!("example.com/{qs}"),
19//! "example.com/?q=%F0%9F%8D%8E%20apple&tasty=true&category=fruits%20and%20vegetables?"
20//! );
21//! ```
22
23#![deny(unsafe_code)]
24
25mod slim;
26
27use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
28use std::fmt::{Debug, Display, Formatter, Write};
29
30pub use slim::{QueryStringSimple, WrappedQueryString};
31
32/// https://url.spec.whatwg.org/#query-percent-encode-set
33pub(crate) const QUERY: &AsciiSet = &CONTROLS
34 .add(b' ')
35 .add(b'"')
36 .add(b'#')
37 .add(b'<')
38 .add(b'>')
39 // The following values are not strictly required by RFC 3986 but could help resolving recursion
40 // where a URL is passed as a value. In these cases, occurrences of equal signs and ampersands
41 // could break parsing.
42 // By a similar logic, encoding the percent sign helps to resolve ambiguity.
43 // The plus sign is also added to the set as to not confuse it with a space.
44 .add(b'%')
45 .add(b'&')
46 .add(b'=')
47 .add(b'+');
48
49/// A query string builder for percent encoding key-value pairs.
50///
51/// ## Example
52///
53/// ```
54/// use query_string_builder::QueryString;
55///
56/// let qs = QueryString::dynamic()
57/// .with_value("q", "apple")
58/// .with_value("category", "fruits and vegetables");
59///
60/// assert_eq!(
61/// format!("https://example.com/{qs}"),
62/// "https://example.com/?q=apple&category=fruits%20and%20vegetables"
63/// );
64/// ```
65#[derive(Debug, Clone)]
66pub struct QueryString {
67 pairs: Vec<Kvp>,
68}
69
70impl QueryString {
71 /// Creates a new, empty query string builder.
72 ///
73 /// ## Example
74 ///
75 /// ```
76 /// use query_string_builder::QueryString;
77 ///
78 /// let weight: &f32 = &99.9;
79 ///
80 /// let qs = QueryString::simple()
81 /// .with_value("q", "apple")
82 /// .with_value("category", "fruits and vegetables")
83 /// .with_opt_value("weight", Some(weight));
84 ///
85 /// assert_eq!(
86 /// format!("https://example.com/{qs}"),
87 /// "https://example.com/?q=apple&category=fruits%20and%20vegetables&weight=99.9"
88 /// );
89 /// ```
90 #[allow(clippy::new_ret_no_self)]
91 pub fn simple() -> QueryStringSimple {
92 QueryStringSimple::default()
93 }
94
95 /// Creates a new, empty query string builder.
96 pub fn dynamic() -> Self {
97 Self {
98 pairs: Vec::default(),
99 }
100 }
101
102 /// Appends a key-value pair to the query string.
103 ///
104 /// ## Example
105 ///
106 /// ```
107 /// use query_string_builder::QueryString;
108 ///
109 /// let qs = QueryString::dynamic()
110 /// .with_value("q", "🍎 apple")
111 /// .with_value("category", "fruits and vegetables")
112 /// .with_value("answer", 42);
113 ///
114 /// assert_eq!(
115 /// format!("https://example.com/{qs}"),
116 /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&answer=42"
117 /// );
118 /// ```
119 pub fn with_value<K: ToString, V: ToString>(mut self, key: K, value: V) -> Self {
120 self.pairs.push(Kvp {
121 key: key.to_string(),
122 value: value.to_string(),
123 });
124 self
125 }
126
127 /// Appends a key-value pair to the query string if the value exists.
128 ///
129 /// ## Example
130 ///
131 /// ```
132 /// use query_string_builder::QueryString;
133 ///
134 /// let qs = QueryString::dynamic()
135 /// .with_opt_value("q", Some("🍎 apple"))
136 /// .with_opt_value("f", None::<String>)
137 /// .with_opt_value("category", Some("fruits and vegetables"))
138 /// .with_opt_value("works", Some(true));
139 ///
140 /// assert_eq!(
141 /// format!("https://example.com/{qs}"),
142 /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&works=true"
143 /// );
144 /// ```
145 pub fn with_opt_value<K: ToString, V: ToString>(self, key: K, value: Option<V>) -> Self {
146 if let Some(value) = value {
147 self.with_value(key, value)
148 } else {
149 self
150 }
151 }
152
153 /// Appends a key-value pair to the query string.
154 ///
155 /// ## Example
156 ///
157 /// ```
158 /// use query_string_builder::QueryString;
159 ///
160 /// let mut qs = QueryString::dynamic();
161 /// qs.push("q", "apple");
162 /// qs.push("category", "fruits and vegetables");
163 ///
164 /// assert_eq!(
165 /// format!("https://example.com/{qs}"),
166 /// "https://example.com/?q=apple&category=fruits%20and%20vegetables"
167 /// );
168 /// ```
169 pub fn push<K: ToString, V: ToString>(&mut self, key: K, value: V) -> &Self {
170 self.pairs.push(Kvp {
171 key: key.to_string(),
172 value: value.to_string(),
173 });
174 self
175 }
176
177 /// Appends a key-value pair to the query string if the value exists.
178 ///
179 /// ## Example
180 ///
181 /// ```
182 /// use query_string_builder::QueryString;
183 ///
184 /// let mut qs = QueryString::dynamic();
185 /// qs.push_opt("q", None::<String>);
186 /// qs.push_opt("q", Some("🍎 apple"));
187 ///
188 /// assert_eq!(
189 /// format!("https://example.com/{qs}"),
190 /// "https://example.com/?q=%F0%9F%8D%8E%20apple"
191 /// );
192 /// ```
193 pub fn push_opt<K: ToString, V: ToString>(&mut self, key: K, value: Option<V>) -> &Self {
194 if let Some(value) = value {
195 self.push(key, value)
196 } else {
197 self
198 }
199 }
200
201 /// Determines the number of key-value pairs currently in the builder.
202 pub fn len(&self) -> usize {
203 self.pairs.len()
204 }
205
206 /// Determines if the builder is currently empty.
207 pub fn is_empty(&self) -> bool {
208 self.pairs.is_empty()
209 }
210
211 /// Appends another query string builder's values.
212 ///
213 /// ## Example
214 ///
215 /// ```
216 /// use query_string_builder::QueryString;
217 ///
218 /// let mut qs = QueryString::dynamic().with_value("q", "apple");
219 /// let more = QueryString::dynamic().with_value("q", "pear");
220 ///
221 /// qs.append(more);
222 ///
223 /// assert_eq!(
224 /// format!("https://example.com/{qs}"),
225 /// "https://example.com/?q=apple&q=pear"
226 /// );
227 /// ```
228 pub fn append(&mut self, mut other: QueryString) {
229 self.pairs.append(&mut other.pairs)
230 }
231
232 /// Appends another query string builder's values, consuming both types.
233 ///
234 /// ## Example
235 ///
236 /// ```
237 /// use query_string_builder::QueryString;
238 ///
239 /// let qs = QueryString::dynamic().with_value("q", "apple");
240 /// let more = QueryString::dynamic().with_value("q", "pear");
241 ///
242 /// let qs = qs.append_into(more);
243 ///
244 /// assert_eq!(
245 /// format!("https://example.com/{qs}"),
246 /// "https://example.com/?q=apple&q=pear"
247 /// );
248 /// ```
249 pub fn append_into(mut self, mut other: QueryString) -> Self {
250 self.pairs.append(&mut other.pairs);
251 self
252 }
253}
254
255impl Display for QueryString {
256 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
257 if self.pairs.is_empty() {
258 Ok(())
259 } else {
260 f.write_char('?')?;
261 for (i, pair) in self.pairs.iter().enumerate() {
262 if i > 0 {
263 f.write_char('&')?;
264 }
265
266 Display::fmt(&utf8_percent_encode(&pair.key, QUERY), f)?;
267 f.write_char('=')?;
268 Display::fmt(&utf8_percent_encode(&pair.value, QUERY), f)?;
269 }
270 Ok(())
271 }
272 }
273}
274
275#[derive(Debug, Clone)]
276struct Kvp {
277 key: String,
278 value: String,
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_empty() {
287 let qs = QueryStringSimple::default();
288 assert_eq!(qs.to_string(), "");
289 assert_eq!(qs.len(), 0);
290 assert!(qs.is_empty());
291 }
292
293 #[test]
294 fn test_simple() {
295 let qs = QueryString::dynamic()
296 .with_value("q", "apple???")
297 .with_value("category", "fruits and vegetables")
298 .with_value("tasty", true)
299 .with_value("weight", 99.9);
300 assert_eq!(
301 qs.to_string(),
302 "?q=apple???&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
303 );
304 assert_eq!(qs.len(), 4);
305 assert!(!qs.is_empty());
306 }
307
308 #[test]
309 fn test_encoding() {
310 let qs = QueryString::dynamic()
311 .with_value("q", "Grünkohl")
312 .with_value("category", "Gemüse");
313 assert_eq!(qs.to_string(), "?q=Gr%C3%BCnkohl&category=Gem%C3%BCse");
314 }
315
316 #[test]
317 fn test_emoji() {
318 let qs = QueryString::dynamic()
319 .with_value("q", "🥦")
320 .with_value("🍽️", "🍔🍕");
321 assert_eq!(
322 qs.to_string(),
323 "?q=%F0%9F%A5%A6&%F0%9F%8D%BD%EF%B8%8F=%F0%9F%8D%94%F0%9F%8D%95"
324 );
325 }
326
327 #[test]
328 fn test_optional() {
329 let qs = QueryString::dynamic()
330 .with_value("q", "celery")
331 .with_opt_value("taste", None::<String>)
332 .with_opt_value("category", Some("fruits and vegetables"))
333 .with_opt_value("tasty", Some(true))
334 .with_opt_value("weight", Some(99.9));
335 assert_eq!(
336 qs.to_string(),
337 "?q=celery&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
338 );
339 assert_eq!(qs.len(), 4); // not five!
340 }
341
342 #[test]
343 fn test_push_optional() {
344 let mut qs = QueryString::dynamic();
345 qs.push("a", "apple");
346 qs.push_opt("b", None::<String>);
347 qs.push_opt("c", Some("🍎 apple"));
348
349 assert_eq!(
350 format!("https://example.com/{qs}"),
351 "https://example.com/?a=apple&c=%F0%9F%8D%8E%20apple"
352 );
353 }
354
355 #[test]
356 fn test_append() {
357 let qs = QueryString::dynamic().with_value("q", "apple");
358 let more = QueryString::dynamic().with_value("q", "pear");
359
360 let mut qs = qs.append_into(more);
361 qs.append(QueryString::dynamic().with_value("answer", "42"));
362
363 assert_eq!(
364 format!("https://example.com/{qs}"),
365 "https://example.com/?q=apple&q=pear&answer=42"
366 );
367 }
368
369 #[test]
370 fn test_characters() {
371 let tests = vec![
372 ("space", " ", "%20"),
373 ("double_quote", "\"", "%22"),
374 ("hash", "#", "%23"),
375 ("less_than", "<", "%3C"),
376 ("equals", "=", "%3D"),
377 ("greater_than", ">", "%3E"),
378 ("percent", "%", "%25"),
379 ("ampersand", "&", "%26"),
380 ("plus", "+", "%2B"),
381 //
382 ("dollar", "$", "$"),
383 ("single_quote", "'", "'"),
384 ("comma", ",", ","),
385 ("forward_slash", "/", "/"),
386 ("colon", ":", ":"),
387 ("semicolon", ";", ";"),
388 ("question_mark", "?", "?"),
389 ("at", "@", "@"),
390 ("left_bracket", "[", "["),
391 ("backslash", "\\", "\\"),
392 ("right_bracket", "]", "]"),
393 ("caret", "^", "^"),
394 ("underscore", "_", "_"),
395 ("grave", "^", "^"),
396 ("left_curly", "{", "{"),
397 ("pipe", "|", "|"),
398 ("right_curly", "}", "}"),
399 ];
400
401 let mut qs = QueryString::dynamic();
402 for (key, value, _) in &tests {
403 qs.push(key.to_string(), value.to_string());
404 }
405
406 let mut expected = String::new();
407 for (i, (key, _, value)) in tests.iter().enumerate() {
408 if i > 0 {
409 expected.push('&');
410 }
411 expected.push_str(&format!("{key}={value}"));
412 }
413
414 assert_eq!(
415 format!("https://example.com/{qs}"),
416 format!("https://example.com/?{expected}")
417 );
418 }
419}