Skip to main content

query_string_builder/
borrowed.rs

1//! The borrowing, zero-allocation query string builder.
2
3use crate::encode::write_encoded;
4use crate::{QUERY, QueryStringOwned};
5use percent_encoding::utf8_percent_encode;
6use std::fmt::{self, Debug, Display, Formatter, Write};
7
8/// A borrowed key or value of a query string pair.
9///
10/// You usually don't interact with this type directly; it is produced by the
11/// [`IntoPart`] conversions accepted by [`QueryString`]'s methods.
12#[derive(Clone, Copy)]
13pub enum Part<'a> {
14    /// A plain string slice.
15    Str(&'a str),
16    /// Any other borrowed [`Display`] value, rendered on demand.
17    Display(&'a dyn Display),
18}
19
20impl Display for Part<'_> {
21    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
22        match self {
23            Part::Str(s) => Display::fmt(s, f),
24            Part::Display(d) => Display::fmt(d, f),
25        }
26    }
27}
28
29impl Part<'_> {
30    /// Writes this part into the formatter, percent-encoded, without
31    /// allocating intermediate strings.
32    fn write_encoded_to(&self, f: &mut Formatter<'_>) -> fmt::Result {
33        match self {
34            Part::Str(s) => {
35                for piece in utf8_percent_encode(s, QUERY) {
36                    f.write_str(piece)?;
37                }
38                Ok(())
39            }
40            Part::Display(d) => write_encoded(f, *d),
41        }
42    }
43}
44
45/// Conversion into a borrowed query string [`Part`].
46///
47/// Implemented for `&str` and for `&T` of any [`Display`] type, so string
48/// literals, `&String`, `&i32`, `&bool` etc. can all be passed to
49/// [`QueryString`]'s methods directly.
50pub trait IntoPart<'a> {
51    /// Performs the conversion.
52    fn into_part(self) -> Part<'a>;
53}
54
55impl<'a> IntoPart<'a> for &'a str {
56    fn into_part(self) -> Part<'a> {
57        Part::Str(self)
58    }
59}
60
61impl<'a, T: Display> IntoPart<'a> for &'a T {
62    fn into_part(self) -> Part<'a> {
63        Part::Display(self)
64    }
65}
66
67/// A zero-allocation query string builder for percent encoding key-value pairs.
68///
69/// This builder borrows all keys and values. Building performs a single [`Vec`]
70/// allocation for the pair list; rendering percent-encodes each value on the
71/// fly without allocating intermediate strings.
72///
73/// If you need a builder without a lifetime parameter — e.g. to store it in a
74/// struct or return it from a function that owns the values — use
75/// [`QueryStringOwned`] or convert via [`into_owned`](Self::into_owned).
76///
77/// ## Example
78///
79/// ```
80/// use query_string_builder::QueryString;
81///
82/// let tasty = true;
83/// let qs = QueryString::new()
84///     .with("q", "apple")
85///     .with("tasty", &tasty)
86///     .with_opt("category", Some("fruits and vegetables"));
87///
88/// assert_eq!(
89///     format!("https://example.com/{qs}"),
90///     "https://example.com/?q=apple&tasty=true&category=fruits%20and%20vegetables"
91/// );
92/// ```
93///
94/// ## Borrowing footgun
95///
96/// Because the builder borrows its values, temporaries created inline do not
97/// live long enough — bind them to a variable first:
98///
99/// ```compile_fail
100/// use query_string_builder::QueryString;
101///
102/// let qs = QueryString::new().with("answer", &42.to_string()); // temporary dropped here
103/// println!("{qs}");
104/// ```
105///
106/// ```
107/// use query_string_builder::QueryString;
108///
109/// let answer = 42.to_string();
110/// let qs = QueryString::new().with("answer", &answer);
111/// assert_eq!(qs.to_string(), "?answer=42");
112/// ```
113#[derive(Clone, Default)]
114pub struct QueryString<'a> {
115    pairs: Vec<(Part<'a>, Part<'a>)>,
116}
117
118impl<'a> QueryString<'a> {
119    /// Creates a new, empty query string builder.
120    pub fn new() -> Self {
121        Self { pairs: Vec::new() }
122    }
123
124    /// Appends a key-value pair to the query string.
125    ///
126    /// ## Example
127    ///
128    /// ```
129    /// use query_string_builder::QueryString;
130    ///
131    /// let answer = 42;
132    /// let qs = QueryString::new()
133    ///     .with("q", "🍎 apple")
134    ///     .with("category", "fruits and vegetables")
135    ///     .with("answer", &answer);
136    ///
137    /// assert_eq!(
138    ///     format!("https://example.com/{qs}"),
139    ///     "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&answer=42"
140    /// );
141    /// ```
142    #[doc(alias = "with_value")]
143    pub fn with<K, V>(mut self, key: K, value: V) -> Self
144    where
145        K: IntoPart<'a>,
146        V: IntoPart<'a>,
147    {
148        self.pairs.push((key.into_part(), value.into_part()));
149        self
150    }
151
152    /// Appends a key-value pair to the query string if the value exists.
153    ///
154    /// ## Example
155    ///
156    /// ```
157    /// use query_string_builder::QueryString;
158    ///
159    /// let works = true;
160    /// let qs = QueryString::new()
161    ///     .with_opt("q", Some("🍎 apple"))
162    ///     .with_opt("f", None::<&str>)
163    ///     .with_opt("category", Some("fruits and vegetables"))
164    ///     .with_opt("works", Some(&works));
165    ///
166    /// assert_eq!(
167    ///     format!("https://example.com/{qs}"),
168    ///     "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&works=true"
169    /// );
170    /// ```
171    #[doc(alias = "with_opt_value")]
172    pub fn with_opt<K, V>(self, key: K, value: Option<V>) -> Self
173    where
174        K: IntoPart<'a>,
175        V: IntoPart<'a>,
176    {
177        if let Some(value) = value {
178            self.with(key, value)
179        } else {
180            self
181        }
182    }
183
184    /// Appends a key-value pair to the query string.
185    ///
186    /// ## Example
187    ///
188    /// ```
189    /// use query_string_builder::QueryString;
190    ///
191    /// let mut qs = QueryString::new();
192    /// qs.push("q", "apple");
193    /// qs.push("category", "fruits and vegetables");
194    ///
195    /// assert_eq!(
196    ///     format!("https://example.com/{qs}"),
197    ///     "https://example.com/?q=apple&category=fruits%20and%20vegetables"
198    /// );
199    /// ```
200    pub fn push<K, V>(&mut self, key: K, value: V) -> &mut Self
201    where
202        K: IntoPart<'a>,
203        V: IntoPart<'a>,
204    {
205        self.pairs.push((key.into_part(), value.into_part()));
206        self
207    }
208
209    /// Appends a key-value pair to the query string if the value exists.
210    ///
211    /// ## Example
212    ///
213    /// ```
214    /// use query_string_builder::QueryString;
215    ///
216    /// let mut qs = QueryString::new();
217    /// qs.push_opt("q", None::<&str>);
218    /// qs.push_opt("q", Some("🍎 apple"));
219    ///
220    /// assert_eq!(
221    ///     format!("https://example.com/{qs}"),
222    ///     "https://example.com/?q=%F0%9F%8D%8E%20apple"
223    /// );
224    /// ```
225    pub fn push_opt<K, V>(&mut self, key: K, value: Option<V>) -> &mut Self
226    where
227        K: IntoPart<'a>,
228        V: IntoPart<'a>,
229    {
230        if let Some(value) = value {
231            self.push(key, value)
232        } else {
233            self
234        }
235    }
236
237    /// Determines the number of key-value pairs currently in the builder.
238    pub fn len(&self) -> usize {
239        self.pairs.len()
240    }
241
242    /// Determines if the builder is currently empty.
243    pub fn is_empty(&self) -> bool {
244        self.pairs.is_empty()
245    }
246
247    /// Appends another query string builder's values.
248    ///
249    /// ## Example
250    ///
251    /// ```
252    /// use query_string_builder::QueryString;
253    ///
254    /// let mut qs = QueryString::new().with("q", "apple");
255    /// let more = QueryString::new().with("q", "pear");
256    ///
257    /// qs.append(more);
258    ///
259    /// assert_eq!(
260    ///     format!("https://example.com/{qs}"),
261    ///     "https://example.com/?q=apple&q=pear"
262    /// );
263    /// ```
264    pub fn append(&mut self, mut other: QueryString<'a>) {
265        self.pairs.append(&mut other.pairs)
266    }
267
268    /// Appends another query string builder's values, consuming both types.
269    ///
270    /// ## Example
271    ///
272    /// ```
273    /// use query_string_builder::QueryString;
274    ///
275    /// let qs = QueryString::new().with("q", "apple");
276    /// let more = QueryString::new().with("q", "pear");
277    ///
278    /// let qs = qs.append_into(more);
279    ///
280    /// assert_eq!(
281    ///     format!("https://example.com/{qs}"),
282    ///     "https://example.com/?q=apple&q=pear"
283    /// );
284    /// ```
285    pub fn append_into(mut self, mut other: QueryString<'a>) -> Self {
286        self.pairs.append(&mut other.pairs);
287        self
288    }
289
290    /// Converts this borrowing builder into a [`QueryStringOwned`] by rendering
291    /// each key and value to an owned [`String`].
292    ///
293    /// Useful for building cheaply with borrows and then storing or returning
294    /// the result past the borrows' lifetimes.
295    ///
296    /// ## Example
297    ///
298    /// ```
299    /// use query_string_builder::{QueryString, QueryStringOwned};
300    ///
301    /// let qs: QueryStringOwned = {
302    ///     let q = String::from("apple");
303    ///     QueryString::new().with("q", &q).into_owned()
304    /// };
305    ///
306    /// assert_eq!(qs.to_string(), "?q=apple");
307    /// ```
308    pub fn into_owned(self) -> QueryStringOwned {
309        QueryStringOwned::from_pairs(
310            self.pairs
311                .into_iter()
312                .map(|(key, value)| (key.to_string(), value.to_string()))
313                .collect(),
314        )
315    }
316}
317
318impl Display for QueryString<'_> {
319    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
320        if self.pairs.is_empty() {
321            return Ok(());
322        }
323        f.write_char('?')?;
324        for (i, (key, value)) in self.pairs.iter().enumerate() {
325            if i > 0 {
326                f.write_char('&')?;
327            }
328            key.write_encoded_to(f)?;
329            f.write_char('=')?;
330            value.write_encoded_to(f)?;
331        }
332        Ok(())
333    }
334}
335
336impl Debug for QueryString<'_> {
337    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
338        f.debug_map()
339            .entries(
340                self.pairs
341                    .iter()
342                    .map(|(key, value)| (key.to_string(), value.to_string())),
343            )
344            .finish()
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_empty() {
354        let qs = QueryString::new();
355        assert_eq!(qs.to_string(), "");
356        assert_eq!(qs.len(), 0);
357        assert!(qs.is_empty());
358    }
359
360    #[test]
361    fn test_simple() {
362        let tasty = true;
363        let weight = 99.9;
364        let qs = QueryString::new()
365            .with("q", "apple???")
366            .with("category", "fruits and vegetables")
367            .with("tasty", &tasty)
368            .with("weight", &weight);
369        assert_eq!(
370            qs.to_string(),
371            "?q=apple???&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
372        );
373        assert_eq!(qs.len(), 4);
374        assert!(!qs.is_empty());
375    }
376
377    #[test]
378    fn test_encoding() {
379        let qs = QueryString::new()
380            .with("q", "Grünkohl")
381            .with("category", "Gemüse");
382        assert_eq!(qs.to_string(), "?q=Gr%C3%BCnkohl&category=Gem%C3%BCse");
383    }
384
385    #[test]
386    fn test_emoji() {
387        let qs = QueryString::new().with("q", "🥦").with("🍽️", "🍔🍕");
388        assert_eq!(
389            qs.to_string(),
390            "?q=%F0%9F%A5%A6&%F0%9F%8D%BD%EF%B8%8F=%F0%9F%8D%94%F0%9F%8D%95"
391        );
392    }
393
394    #[test]
395    fn test_optional() {
396        let tasty = true;
397        let weight = 99.9;
398        let qs = QueryString::new()
399            .with("q", "celery")
400            .with_opt("taste", None::<&str>)
401            .with_opt("category", Some("fruits and vegetables"))
402            .with_opt("tasty", Some(&tasty))
403            .with_opt("weight", Some(&weight));
404        assert_eq!(
405            qs.to_string(),
406            "?q=celery&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
407        );
408        assert_eq!(qs.len(), 4); // not five!
409    }
410
411    #[test]
412    fn test_push_optional() {
413        let mut qs = QueryString::new();
414        qs.push("a", "apple");
415        qs.push_opt("b", None::<&str>);
416        qs.push_opt("c", Some("🍎 apple"));
417
418        assert_eq!(
419            format!("https://example.com/{qs}"),
420            "https://example.com/?a=apple&c=%F0%9F%8D%8E%20apple"
421        );
422    }
423
424    #[test]
425    fn test_append() {
426        let qs = QueryString::new().with("q", "apple");
427        let more = QueryString::new().with("q", "pear");
428
429        let mut qs = qs.append_into(more);
430        qs.append(QueryString::new().with("answer", "42"));
431
432        assert_eq!(
433            format!("https://example.com/{qs}"),
434            "https://example.com/?q=apple&q=pear&answer=42"
435        );
436    }
437
438    #[test]
439    fn test_characters() {
440        let tests = vec![
441            ("space", " ", "%20"),
442            ("double_quote", "\"", "%22"),
443            ("hash", "#", "%23"),
444            ("less_than", "<", "%3C"),
445            ("equals", "=", "%3D"),
446            ("greater_than", ">", "%3E"),
447            ("percent", "%", "%25"),
448            ("ampersand", "&", "%26"),
449            ("plus", "+", "%2B"),
450            //
451            ("dollar", "$", "$"),
452            ("single_quote", "'", "'"),
453            ("comma", ",", ","),
454            ("forward_slash", "/", "/"),
455            ("colon", ":", ":"),
456            ("semicolon", ";", ";"),
457            ("question_mark", "?", "?"),
458            ("at", "@", "@"),
459            ("left_bracket", "[", "["),
460            ("backslash", "\\", "\\"),
461            ("right_bracket", "]", "]"),
462            ("caret", "^", "^"),
463            ("underscore", "_", "_"),
464            ("grave", "^", "^"),
465            ("left_curly", "{", "{"),
466            ("pipe", "|", "|"),
467            ("right_curly", "}", "}"),
468        ];
469
470        let mut qs = QueryString::new();
471        for (key, value, _) in &tests {
472            qs.push(*key, *value);
473        }
474
475        let mut expected = String::new();
476        for (i, (key, _, value)) in tests.iter().enumerate() {
477            if i > 0 {
478                expected.push('&');
479            }
480            expected.push_str(&format!("{key}={value}"));
481        }
482
483        assert_eq!(
484            format!("https://example.com/{qs}"),
485            format!("https://example.com/?{expected}")
486        );
487    }
488
489    #[test]
490    fn test_non_string_refs() {
491        let count = 12i32;
492        let tasty = true;
493        let weight = 99.9f64;
494        let owned_string = String::from("kale");
495        let qs = QueryString::new()
496            .with("count", &count)
497            .with("tasty", &tasty)
498            .with("weight", &weight)
499            .with("q", &owned_string);
500        assert_eq!(qs.to_string(), "?count=12&tasty=true&weight=99.9&q=kale");
501    }
502
503    #[test]
504    fn test_into_owned() {
505        let owned = {
506            let q = String::from("Grünkohl");
507            QueryString::new().with("q", &q).into_owned()
508        };
509        assert_eq!(owned.to_string(), "?q=Gr%C3%BCnkohl");
510        assert_eq!(owned.len(), 1);
511    }
512
513    #[test]
514    fn test_debug() {
515        let qs = QueryString::new().with("q", "apple");
516        assert_eq!(format!("{qs:?}"), r#"{"q": "apple"}"#);
517    }
518
519    #[test]
520    fn test_clone_default() {
521        let qs = QueryString::default().with("q", "apple");
522        let clone = qs.clone();
523        assert_eq!(clone.to_string(), qs.to_string());
524    }
525}