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}