Skip to main content

query_string_builder/
owned.rs

1//! The owning, allocation-friendly query string builder.
2
3use crate::QUERY;
4use percent_encoding::utf8_percent_encode;
5use std::fmt::{self, Display, Formatter, Write};
6
7/// An owning query string builder for percent encoding key-value pairs.
8///
9/// This is the lifetime-free twin of [`QueryString`](crate::QueryString): it
10/// eagerly converts keys and values to owned [`String`]s, so it can be stored
11/// in structs, returned from functions and passed around freely.
12///
13/// ## Example
14///
15/// ```
16/// use query_string_builder::QueryStringOwned;
17///
18/// let qs = QueryStringOwned::new()
19///     .with("q", "apple")
20///     .with("tasty", true)
21///     .with_opt("category", Some("fruits and vegetables"));
22///
23/// assert_eq!(
24///     format!("https://example.com/{qs}"),
25///     "https://example.com/?q=apple&tasty=true&category=fruits%20and%20vegetables"
26/// );
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct QueryStringOwned {
30    pairs: Vec<(String, String)>,
31}
32
33impl QueryStringOwned {
34    /// Creates a new, empty query string builder.
35    pub fn new() -> Self {
36        Self { pairs: Vec::new() }
37    }
38
39    pub(crate) fn from_pairs(pairs: Vec<(String, String)>) -> Self {
40        Self { pairs }
41    }
42
43    /// Appends a key-value pair to the query string.
44    ///
45    /// ## Example
46    ///
47    /// ```
48    /// use query_string_builder::QueryStringOwned;
49    ///
50    /// let qs = QueryStringOwned::new()
51    ///     .with("q", "🍎 apple")
52    ///     .with("category", "fruits and vegetables")
53    ///     .with("answer", 42);
54    ///
55    /// assert_eq!(
56    ///     format!("https://example.com/{qs}"),
57    ///     "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&answer=42"
58    /// );
59    /// ```
60    #[doc(alias = "with_value")]
61    pub fn with<K: ToString, V: ToString>(mut self, key: K, value: V) -> Self {
62        self.pairs.push((key.to_string(), value.to_string()));
63        self
64    }
65
66    /// Appends a key-value pair to the query string if the value exists.
67    ///
68    /// ## Example
69    ///
70    /// ```
71    /// use query_string_builder::QueryStringOwned;
72    ///
73    /// let qs = QueryStringOwned::new()
74    ///     .with_opt("q", Some("🍎 apple"))
75    ///     .with_opt("f", None::<String>)
76    ///     .with_opt("category", Some("fruits and vegetables"))
77    ///     .with_opt("works", Some(true));
78    ///
79    /// assert_eq!(
80    ///     format!("https://example.com/{qs}"),
81    ///     "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&works=true"
82    /// );
83    /// ```
84    #[doc(alias = "with_opt_value")]
85    pub fn with_opt<K: ToString, V: ToString>(self, key: K, value: Option<V>) -> Self {
86        if let Some(value) = value {
87            self.with(key, value)
88        } else {
89            self
90        }
91    }
92
93    /// Appends a key-value pair to the query string.
94    ///
95    /// ## Example
96    ///
97    /// ```
98    /// use query_string_builder::QueryStringOwned;
99    ///
100    /// let mut qs = QueryStringOwned::new();
101    /// qs.push("q", "apple");
102    /// qs.push("category", "fruits and vegetables");
103    ///
104    /// assert_eq!(
105    ///     format!("https://example.com/{qs}"),
106    ///     "https://example.com/?q=apple&category=fruits%20and%20vegetables"
107    /// );
108    /// ```
109    pub fn push<K: ToString, V: ToString>(&mut self, key: K, value: V) -> &mut Self {
110        self.pairs.push((key.to_string(), value.to_string()));
111        self
112    }
113
114    /// Appends a key-value pair to the query string if the value exists.
115    ///
116    /// ## Example
117    ///
118    /// ```
119    /// use query_string_builder::QueryStringOwned;
120    ///
121    /// let mut qs = QueryStringOwned::new();
122    /// qs.push_opt("q", None::<String>);
123    /// qs.push_opt("q", Some("🍎 apple"));
124    ///
125    /// assert_eq!(
126    ///     format!("https://example.com/{qs}"),
127    ///     "https://example.com/?q=%F0%9F%8D%8E%20apple"
128    /// );
129    /// ```
130    pub fn push_opt<K: ToString, V: ToString>(&mut self, key: K, value: Option<V>) -> &mut Self {
131        if let Some(value) = value {
132            self.push(key, value)
133        } else {
134            self
135        }
136    }
137
138    /// Determines the number of key-value pairs currently in the builder.
139    pub fn len(&self) -> usize {
140        self.pairs.len()
141    }
142
143    /// Determines if the builder is currently empty.
144    pub fn is_empty(&self) -> bool {
145        self.pairs.is_empty()
146    }
147
148    /// Appends another query string builder's values.
149    ///
150    /// ## Example
151    ///
152    /// ```
153    /// use query_string_builder::QueryStringOwned;
154    ///
155    /// let mut qs = QueryStringOwned::new().with("q", "apple");
156    /// let more = QueryStringOwned::new().with("q", "pear");
157    ///
158    /// qs.append(more);
159    ///
160    /// assert_eq!(
161    ///     format!("https://example.com/{qs}"),
162    ///     "https://example.com/?q=apple&q=pear"
163    /// );
164    /// ```
165    pub fn append(&mut self, mut other: QueryStringOwned) {
166        self.pairs.append(&mut other.pairs)
167    }
168
169    /// Appends another query string builder's values, consuming both types.
170    ///
171    /// ## Example
172    ///
173    /// ```
174    /// use query_string_builder::QueryStringOwned;
175    ///
176    /// let qs = QueryStringOwned::new().with("q", "apple");
177    /// let more = QueryStringOwned::new().with("q", "pear");
178    ///
179    /// let qs = qs.append_into(more);
180    ///
181    /// assert_eq!(
182    ///     format!("https://example.com/{qs}"),
183    ///     "https://example.com/?q=apple&q=pear"
184    /// );
185    /// ```
186    pub fn append_into(mut self, mut other: QueryStringOwned) -> Self {
187        self.pairs.append(&mut other.pairs);
188        self
189    }
190}
191
192impl Display for QueryStringOwned {
193    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
194        if self.pairs.is_empty() {
195            return Ok(());
196        }
197        f.write_char('?')?;
198        for (i, (key, value)) in self.pairs.iter().enumerate() {
199            if i > 0 {
200                f.write_char('&')?;
201            }
202            Display::fmt(&utf8_percent_encode(key, QUERY), f)?;
203            f.write_char('=')?;
204            Display::fmt(&utf8_percent_encode(value, QUERY), f)?;
205        }
206        Ok(())
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_empty() {
216        let qs = QueryStringOwned::new();
217        assert_eq!(qs.to_string(), "");
218        assert_eq!(qs.len(), 0);
219        assert!(qs.is_empty());
220    }
221
222    #[test]
223    fn test_simple() {
224        let qs = QueryStringOwned::new()
225            .with("q", "apple???")
226            .with("category", "fruits and vegetables")
227            .with("tasty", true)
228            .with("weight", 99.9);
229        assert_eq!(
230            qs.to_string(),
231            "?q=apple???&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
232        );
233        assert_eq!(qs.len(), 4);
234        assert!(!qs.is_empty());
235    }
236
237    #[test]
238    fn test_encoding() {
239        let qs = QueryStringOwned::new()
240            .with("q", "Grünkohl")
241            .with("category", "Gemüse");
242        assert_eq!(qs.to_string(), "?q=Gr%C3%BCnkohl&category=Gem%C3%BCse");
243    }
244
245    #[test]
246    fn test_emoji() {
247        let qs = QueryStringOwned::new().with("q", "🥦").with("🍽️", "🍔🍕");
248        assert_eq!(
249            qs.to_string(),
250            "?q=%F0%9F%A5%A6&%F0%9F%8D%BD%EF%B8%8F=%F0%9F%8D%94%F0%9F%8D%95"
251        );
252    }
253
254    #[test]
255    fn test_optional() {
256        let qs = QueryStringOwned::new()
257            .with("q", "celery")
258            .with_opt("taste", None::<String>)
259            .with_opt("category", Some("fruits and vegetables"))
260            .with_opt("tasty", Some(true))
261            .with_opt("weight", Some(99.9));
262        assert_eq!(
263            qs.to_string(),
264            "?q=celery&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
265        );
266        assert_eq!(qs.len(), 4); // not five!
267    }
268
269    #[test]
270    fn test_push_optional() {
271        let mut qs = QueryStringOwned::new();
272        qs.push("a", "apple");
273        qs.push_opt("b", None::<String>);
274        qs.push_opt("c", Some("🍎 apple"));
275
276        assert_eq!(
277            format!("https://example.com/{qs}"),
278            "https://example.com/?a=apple&c=%F0%9F%8D%8E%20apple"
279        );
280    }
281
282    #[test]
283    fn test_append() {
284        let qs = QueryStringOwned::new().with("q", "apple");
285        let more = QueryStringOwned::new().with("q", "pear");
286
287        let mut qs = qs.append_into(more);
288        qs.append(QueryStringOwned::new().with("answer", "42"));
289
290        assert_eq!(
291            format!("https://example.com/{qs}"),
292            "https://example.com/?q=apple&q=pear&answer=42"
293        );
294    }
295
296    #[test]
297    fn test_characters() {
298        let tests = vec![
299            ("space", " ", "%20"),
300            ("double_quote", "\"", "%22"),
301            ("hash", "#", "%23"),
302            ("less_than", "<", "%3C"),
303            ("equals", "=", "%3D"),
304            ("greater_than", ">", "%3E"),
305            ("percent", "%", "%25"),
306            ("ampersand", "&", "%26"),
307            ("plus", "+", "%2B"),
308            //
309            ("dollar", "$", "$"),
310            ("single_quote", "'", "'"),
311            ("comma", ",", ","),
312            ("forward_slash", "/", "/"),
313            ("colon", ":", ":"),
314            ("semicolon", ";", ";"),
315            ("question_mark", "?", "?"),
316            ("at", "@", "@"),
317            ("left_bracket", "[", "["),
318            ("backslash", "\\", "\\"),
319            ("right_bracket", "]", "]"),
320            ("caret", "^", "^"),
321            ("underscore", "_", "_"),
322            ("grave", "^", "^"),
323            ("left_curly", "{", "{"),
324            ("pipe", "|", "|"),
325            ("right_curly", "}", "}"),
326        ];
327
328        let mut qs = QueryStringOwned::new();
329        for (key, value, _) in &tests {
330            qs.push(key, value);
331        }
332
333        let mut expected = String::new();
334        for (i, (key, _, value)) in tests.iter().enumerate() {
335            if i > 0 {
336                expected.push('&');
337            }
338            expected.push_str(&format!("{key}={value}"));
339        }
340
341        assert_eq!(
342            format!("https://example.com/{qs}"),
343            format!("https://example.com/?{expected}")
344        );
345    }
346}